Compare commits
62
Commits
cb36cfa18d
...
1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17ac04856b | ||
|
|
69df0e51b8 | ||
|
|
9a06dc389d | ||
|
|
51f3e98816 | ||
|
|
68ba3a25b9 | ||
|
|
7e9308c48a | ||
|
|
21a34a4bd5 | ||
|
|
ee16441b79 | ||
|
|
74133ec589 | ||
|
|
f650a3a94e | ||
|
|
c10d98f3b4 | ||
|
|
207401b5fb | ||
|
|
d4d7806189 | ||
|
|
497f7743f2 | ||
|
|
2c06265d11 | ||
|
|
930cdfdff9 | ||
|
|
ab3fab3e68 | ||
|
|
dd5789fcd9 | ||
|
|
fca4286d87 | ||
|
|
7cfddfc7ef | ||
|
|
47740e3f4a | ||
|
|
693b4a42f6 | ||
|
|
8f1e227d50 | ||
|
|
0891606321 | ||
|
|
c6f28ff7af | ||
|
|
072a91961f | ||
|
|
7184240dd4 | ||
|
|
9cd0419c0c | ||
|
|
d3266f8feb | ||
|
|
b532507374 | ||
|
|
5c1a8cbe4f | ||
|
|
502fefff33 | ||
|
|
bc3a950d99 | ||
|
|
1af9e59a56 | ||
|
|
13d25a7ae0 | ||
|
|
fc799da000 | ||
|
|
2b091adce8 | ||
|
|
c54210434e | ||
|
|
fcb68cfd8d | ||
|
|
cdecdd2d4a | ||
|
|
47ffae5dea | ||
|
|
43c1a31660 | ||
|
|
afb9a41404 | ||
|
|
1f2082e0fb | ||
|
|
7ce6a24a77 | ||
|
|
450a93150c | ||
|
|
dc53bce6c9 | ||
|
|
32f2b7c7ab | ||
|
|
72422f4336 | ||
|
|
78508ed16e | ||
|
|
8fd80c1a4c | ||
|
|
5fc9bc6191 | ||
|
|
3c2b29ad26 | ||
|
|
6b494a34fe | ||
|
|
1ae0ad121b | ||
|
|
2e061dff28 | ||
|
|
c484b463e3 | ||
|
|
3fbd0b7ba2 | ||
|
|
80c1f4acce | ||
|
|
6995763ccb | ||
|
|
df7a1b33cc | ||
|
|
b80c7c4743 |
No files matched your search
+30
-6
@@ -12,15 +12,39 @@ R2_REGION=auto
|
||||
# Cloudflare (for custom domain, cache rules, Workers if used)
|
||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
||||
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||
MEDIA_DOMAIN=media.example.com
|
||||
MEDIA_DOMAIN=media.linearvoid.tv
|
||||
|
||||
# Chat / Application Database (PostgreSQL or SQLite for local)
|
||||
DATABASE_URL=postgres://user:password@localhost:5432/linearvoid
|
||||
# Or for SQLite (local development):
|
||||
# Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts)
|
||||
# Preferred local setup in this repo uses the real public domains through Cloudflare/Nginx Proxy Manager.
|
||||
VITE_PUBLIC_APP_URL=https://linearvoid.tv
|
||||
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
|
||||
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin
|
||||
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main
|
||||
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
ADMIN_SESSION_TTL_HOURS=12
|
||||
# Optional direct-local fallback when bypassing the public proxy:
|
||||
# VITE_PUBLIC_APP_URL=http://localhost
|
||||
# VITE_PUBLIC_WS_URL=ws://localhost/ws
|
||||
# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8
|
||||
|
||||
# Gitea Container Registry (for self-hosted Docker images)
|
||||
CI_REGISTRY_URL=git.allucanget.biz
|
||||
CI_REGISTRY_USER=your-gitea-username
|
||||
CI_REGISTRY_TOKEN=your-gitea-token
|
||||
CI_REGISTRY_NAMESPACE=zwitschi/linearvoid
|
||||
# Gitea API access (optional, for CI/release automation)
|
||||
CI_SERVER_URL=https://git.allucanget.biz
|
||||
|
||||
# Chat / Application Database (compose-safe PostgreSQL by default, SQLite optional)
|
||||
DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
|
||||
# Or for SQLite-only local development:
|
||||
# DATABASE_URL=sqlite://./data/chat.db
|
||||
|
||||
# Redis (for presence, rate limiting, pub/sub)
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Service Ports (for local docker-compose)
|
||||
PLAYOUT_PORT=8080
|
||||
@@ -28,7 +52,7 @@ CHAT_PORT=3000
|
||||
NGINX_PORT=80
|
||||
|
||||
# Playout configuration
|
||||
PLAYLIST_PATH=/app/playlist.txt
|
||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||
HLS_SEGMENT_DURATION=6
|
||||
HLS_WINDOW_SIZE=24
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types:
|
||||
- completed
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: [ubuntu-latest]
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.allucanget.biz
|
||||
username: ${{ secrets.CI_REGISTRY_USER }}
|
||||
password: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
cat > .env << 'EOF'
|
||||
DATABASE_URL="${{ secrets.DATABASE_URL }}"
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||
HLS_SEGMENT_DURATION=6
|
||||
HLS_WINDOW_SIZE=24
|
||||
LOG_LEVEL=info
|
||||
ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}"
|
||||
ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}"
|
||||
ADMIN_SESSION_TTL_HOURS=12
|
||||
NGINX_PORT=80
|
||||
EOF
|
||||
|
||||
- name: Seed Prometheus config
|
||||
run: |
|
||||
mkdir -p monitoring/prometheus
|
||||
cat > monitoring/prometheus/prometheus.yml << 'EOF'
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
- job_name: 'linearvoid'
|
||||
static_configs:
|
||||
- targets: ['playout:8081', 'chat:3000', 'nginx:80']
|
||||
EOF
|
||||
|
||||
- name: Deploy with docker-compose
|
||||
env:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
|
||||
- name: Health check
|
||||
env:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
# Wait for services to stabilize
|
||||
sleep 20
|
||||
|
||||
# Check chat health
|
||||
echo "Checking chat health..."
|
||||
if ! curl -f http://192.168.88.40:3000/health; then
|
||||
echo "Chat health check failed"
|
||||
docker logs linearvoid-chat --tail 50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check nginx health
|
||||
echo "Checking nginx health..."
|
||||
if ! curl -f http://192.168.88.40/health/; then
|
||||
echo "Nginx health check failed"
|
||||
docker logs linearvoid-nginx --tail 50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Retry playout health check since it may still be initializing
|
||||
echo "Checking playout health..."
|
||||
for i in $(seq 1 6); do
|
||||
if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||
echo "Playout is healthy"
|
||||
break
|
||||
fi
|
||||
echo "Playout not ready yet (attempt $i/6), waiting 5s..."
|
||||
sleep 5
|
||||
done
|
||||
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||
echo "Playout failed to become healthy after 6 attempts"
|
||||
docker logs linearvoid-playout --tail 50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All services healthy"
|
||||
|
||||
- name: Cleanup old images
|
||||
env:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
docker image prune -f --filter "until=24h"
|
||||
@@ -0,0 +1,152 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: [ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
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"
|
||||
|
||||
- name: Build Frontend
|
||||
run: cd frontend && npm run build
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache: true
|
||||
cache-dependency-path: backend/chat/go.sum
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
- name: Download Go modules
|
||||
run: cd backend/chat && GOTOOLCHAIN=local go mod download
|
||||
|
||||
- name: Run Tests
|
||||
run: echo "Tests will be added in future CI steps (per TODO.md)"
|
||||
|
||||
docker:
|
||||
needs: test
|
||||
runs-on: [ubuntu-latest]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.allucanget.biz
|
||||
username: ${{ secrets.CI_REGISTRY_USER }}
|
||||
password: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Playout
|
||||
id: meta-playout
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.allucanget.biz/zwitschi/linearvoid/playout
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Playout image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend/playout
|
||||
file: ./backend/playout/Dockerfile
|
||||
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
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.allucanget.biz/zwitschi/linearvoid/chat
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Chat image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend/chat
|
||||
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
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.allucanget.biz/zwitschi/linearvoid/nginx
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Nginx image
|
||||
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
|
||||
runs-on: [ubuntu-latest]
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Dependency Updates
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-dependencies:
|
||||
runs-on: [ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Update Frontend Dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm update
|
||||
npm audit fix || true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache: true
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
- name: Update Go Dependencies
|
||||
run: |
|
||||
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: |
|
||||
git config user.name "linearvoid-bot"
|
||||
git config user.email "bot@linearvoid.local"
|
||||
git add frontend/package*.json backend/playout/go.* backend/chat/go.*
|
||||
if git diff --cached --quiet; then
|
||||
echo "No dependency changes detected."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: update dependencies"
|
||||
git push origin HEAD:dependency-updates --force
|
||||
|
||||
- name: Create Pull Request via Gitea API
|
||||
run: |
|
||||
RESPONSE=$(curl -s -o /tmp/pr-response.json -w "%{http_code}" \
|
||||
-X POST "$CI_SERVER_URL/api/v1/repos/zwitschi/linearvoid/pulls" \
|
||||
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "chore: weekly dependency updates",
|
||||
"head": "dependency-updates",
|
||||
"base": "main",
|
||||
"body": "Automated weekly dependency updates.\n\nThis PR updates:\n- Frontend npm dependencies\n- Go module dependencies\n\nPlease review the changes and ensure tests pass before merging."
|
||||
}')
|
||||
if [ "$RESPONSE" -eq 201 ]; then
|
||||
PR_URL=$(cat /tmp/pr-response.json | jq -r '.html_url')
|
||||
echo "PR created: $PR_URL"
|
||||
elif [ "$RESPONSE" -eq 409 ]; then
|
||||
echo "PR already exists. Pushing updated commits..."
|
||||
git push origin dependency-updates --force
|
||||
else
|
||||
echo "Error creating PR (HTTP $RESPONSE):"
|
||||
cat /tmp/pr-response.json
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*.*.*" # Trigger on any semantic version tag (e.g., 1.0.0, 2.1.3)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release (e.g., 1.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: [ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.allucanget.biz
|
||||
username: ${{ secrets.CI_REGISTRY_USER }}
|
||||
password: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "::error::VERSION is empty. Provide a version via workflow_dispatch input or push a tag."
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Releasing version: $VERSION"
|
||||
|
||||
- name: Print version to gitea runner log
|
||||
run: |
|
||||
echo "Releasing version: ${{ steps.version.outputs.version }}"
|
||||
echo "===DEBUG==="
|
||||
echo "VERSION: $VERSION"
|
||||
echo "GITHUB_REF: $GITHUB_REF"
|
||||
echo "steps.version.outputs.version: ${{ steps.version.outputs.version }}"
|
||||
echo "github.event_name: ${{ github.event_name }}"
|
||||
|
||||
- name: Extract metadata for Playout
|
||||
id: meta-playout
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.allucanget.biz/zwitschi/linearvoid/playout
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Playout image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend/playout
|
||||
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
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.allucanget.biz/zwitschi/linearvoid/chat
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Chat image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend/chat
|
||||
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
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.allucanget.biz/zwitschi/linearvoid/nginx
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Nginx image
|
||||
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
|
||||
|
||||
- name: Create Release on Gitea
|
||||
run: |
|
||||
curl -X POST "https://git.allucanget.biz/api/v1/repos/zwitschi/linearvoid/releases" \
|
||||
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${{ steps.version.outputs.version }}\",\"name\":\"${{ steps.version.outputs.version }}\",\"body\":\"Release ${{ steps.version.outputs.version }}\n\nSee [CHANGELOG.md](https://git.allucanget.biz/zwitschi/linearvoid/src/branch/main/CHANGELOG.md) for details.\",\"draft\":false,\"prerelease\":false}"
|
||||
@@ -0,0 +1,28 @@
|
||||
# AGENTS.md
|
||||
|
||||
Agents instructions for the linearvoid project.
|
||||
|
||||
## Output
|
||||
|
||||
- use /caveman skill, latest version from <https://github.com/JuliusBrussee/caveman> is <https://github.com/JuliusBrussee/caveman/tree/main/skills/caveman>
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use the architecture documentation (arc42 template and ADRs in `docs`) as the source of truth for design decisions.
|
||||
- Follow the project folder structure in the root README.
|
||||
- Update documentation first before implementing changes to ensure traceability of decisions.
|
||||
|
||||
## Task Management
|
||||
|
||||
- Plan any task in the `TODO.md` file with clear steps and mark them as completed when done. Move completed sections to DONE.md. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
||||
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
|
||||
|
||||
## Security & Secrets Protection
|
||||
|
||||
- **Never commit raw secrets or credentials** to the repository.
|
||||
- **Never print or log raw secrets** in terminal commands or output files.
|
||||
- **Use Gitea Actions Secrets** for all production credentials and environment variables.
|
||||
- **Avoid hardcoding sensitive values** in workflow files (`.yml`). Instead, reference them using `${{ secrets.SECRET_NAME }}`.
|
||||
- **Do not use the `GITEA_` prefix** for custom secrets or environment variables in Gitea, as it is reserved internally and will be silently ignored or returned as empty. Use the `CI_` prefix instead (e.g., `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`).
|
||||
- **Keep `.env` gitignored** and use `.env.example` as the template for local development.
|
||||
- **For CD deployments**, generate the `.env` file dynamically on the runner using a secure step that populates values from Gitea Actions Secrets.
|
||||
@@ -1,11 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Agents instructions for the linearvoid project.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use the architecture documentation (arc42 template and ADRs) as the source of truth for design decisions.
|
||||
- Follow the project folder structure defined in ADR-002 and reflected in the root README.
|
||||
- Update documentation first before implementing changes to ensure traceability of decisions.
|
||||
- Plan any task in the `TODO.md` file with clear steps and mark them as completed when done. Move completed sections to DONE.md. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
||||
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
|
||||
@@ -1,36 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Lint Frontend
|
||||
run: cd frontend && npm run lint || echo "Linting not configured yet"
|
||||
|
||||
- name: Build Frontend
|
||||
run: cd frontend && npm run build
|
||||
|
||||
- name: Validate Playlist Normalization
|
||||
run: cd backend/playout && go run src/playlist.go playlist.txt || echo "Validation script not yet complete"
|
||||
|
||||
- name: Build Docker Images
|
||||
run: docker compose build --no-cache
|
||||
|
||||
- name: Run Tests
|
||||
run: echo "Tests will be added in future CI steps (per TODO.md)"
|
||||
+4
-5
@@ -50,6 +50,9 @@ data/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Demo data (tracked in backend/playout/demodata/ for out-of-the-box playout)
|
||||
!backend/playout/demodata/
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
**/.dockerignore
|
||||
@@ -75,14 +78,10 @@ Thumbs.db
|
||||
*.tfstate.backup
|
||||
.terraform/
|
||||
|
||||
# Monitoring & temp
|
||||
prometheus/
|
||||
grafana/
|
||||
|
||||
# Agent / Tool files
|
||||
.github/instructions/TODO.md
|
||||
.github/instructions/DONE.md
|
||||
# (keep AGENTS.md and other instruction files)
|
||||
# (keep AGENTS.md, MEMORY.md, and other instruction files)
|
||||
|
||||
# Temporary architecture artifacts
|
||||
**/*.tmp
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# API Reference
|
||||
|
||||
This document describes the currently implemented API surfaces in Linear Void.
|
||||
|
||||
## Base URLs
|
||||
|
||||
- Public app and admin: <https://linearvoid.tv>
|
||||
- WebSocket endpoint: <wss://linearvoid.tv/ws>
|
||||
- Local fallback: <http://localhost> and <ws://localhost/ws>
|
||||
|
||||
## Authentication Model
|
||||
|
||||
Admin REST endpoints use a cookie session.
|
||||
|
||||
- Cookie name: linearvoid_admin_session
|
||||
- Cookie attributes: HttpOnly, SameSite=Strict, Secure when request is HTTPS (or X-Forwarded-Proto=https)
|
||||
- Session TTL: controlled by ADMIN_SESSION_TTL_HOURS (default 12h)
|
||||
|
||||
## Chat WebSocket API
|
||||
|
||||
### Connect
|
||||
|
||||
Endpoint:
|
||||
|
||||
- GET /ws?username={name}&room={room}
|
||||
|
||||
Client sends JSON messages over the socket.
|
||||
|
||||
### Message Envelope (client -> server)
|
||||
|
||||
```json
|
||||
{
|
||||
"room": "main",
|
||||
"username": "viewer123",
|
||||
"text": "hello world",
|
||||
"type": "public"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- type is usually public for room chat and private for DM room routing.
|
||||
- Private room names are formatted like dm:userA-userB (sorted usernames).
|
||||
- Moderation/system events are sent as type=system messages.
|
||||
|
||||
### Message Envelope (server -> client)
|
||||
|
||||
```json
|
||||
{
|
||||
"room": "main",
|
||||
"username": "viewer123",
|
||||
"text": "hello world",
|
||||
"type": "public",
|
||||
"timestamp": "2026-08-04T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Session API
|
||||
|
||||
### Login
|
||||
|
||||
- POST /api/admin/login
|
||||
- Auth: none
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "your-password"
|
||||
}
|
||||
```
|
||||
|
||||
Success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"username": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
### Logout
|
||||
|
||||
- POST /api/admin/logout
|
||||
- Auth: optional (clears cookie either way)
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": false
|
||||
}
|
||||
```
|
||||
|
||||
### Session Check
|
||||
|
||||
- GET /api/admin/session
|
||||
- Auth: cookie required
|
||||
|
||||
Success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"username": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
Failure:
|
||||
|
||||
- 401 unauthorized
|
||||
|
||||
## Admin Data API
|
||||
|
||||
All endpoints below require admin authentication.
|
||||
|
||||
### Overview
|
||||
|
||||
- GET /api/admin/overview
|
||||
|
||||
Returns aggregate counters and runtime/system snapshot fields.
|
||||
|
||||
### Playlist
|
||||
|
||||
- GET /api/admin/playlist
|
||||
- PUT /api/admin/playlist
|
||||
|
||||
PUT request:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n"
|
||||
}
|
||||
```
|
||||
|
||||
### Schedule
|
||||
|
||||
- GET /api/admin/schedule
|
||||
|
||||
Returns parsed playlist entries plus matched media metadata/runtime.
|
||||
|
||||
### Moderation
|
||||
|
||||
- GET /api/admin/moderation/logs?limit=50
|
||||
- POST /api/admin/moderation/actions
|
||||
|
||||
Action request:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "mute",
|
||||
"target": "username-or-id",
|
||||
"moderator": "admin",
|
||||
"reason": "spam",
|
||||
"room": "main"
|
||||
}
|
||||
```
|
||||
|
||||
### Analytics
|
||||
|
||||
- GET /api/admin/analytics
|
||||
|
||||
Returns totals (messages, moderation actions, rooms) and top room summary.
|
||||
|
||||
### System
|
||||
|
||||
- GET /api/admin/system
|
||||
|
||||
Returns service health snapshot for chat/playout/redis/database and config metadata.
|
||||
|
||||
### Active Users
|
||||
|
||||
- GET /api/admin/users
|
||||
|
||||
Returns currently discovered presence users (room + username) when Redis is available.
|
||||
|
||||
## Media Library API
|
||||
|
||||
### Media Collection
|
||||
|
||||
- GET /api/admin/media
|
||||
- POST /api/admin/media (multipart upload, legacy path)
|
||||
|
||||
Multipart form field name: file
|
||||
|
||||
### Media Item
|
||||
|
||||
- GET /api/admin/media/{id}
|
||||
- PUT /api/admin/media/{id}
|
||||
- DELETE /api/admin/media/{id}
|
||||
|
||||
PUT request:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"title": "My Clip",
|
||||
"tags": "music,live"
|
||||
}
|
||||
```
|
||||
|
||||
## Resumable Upload API
|
||||
|
||||
Maximum file size: 2GB
|
||||
|
||||
Default chunk size: 8MB
|
||||
|
||||
### Initialize or Resume Session
|
||||
|
||||
- POST /api/admin/media/uploads
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"uploadId": "optional-existing-id",
|
||||
"fileName": "clip.mp4",
|
||||
"fileSize": 123456789,
|
||||
"mimeType": "video/mp4",
|
||||
"chunkSize": 8388608,
|
||||
"totalChunks": 15
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"uploadId": "hex-token",
|
||||
"fileName": "clip.mp4",
|
||||
"fileSize": 123456789,
|
||||
"chunkSize": 8388608,
|
||||
"totalChunks": 15,
|
||||
"uploadedChunks": 3,
|
||||
"progress": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Check Upload Status
|
||||
|
||||
- GET /api/admin/media/uploads/{uploadId}
|
||||
|
||||
Returns same status envelope as initialize.
|
||||
|
||||
### Upload Chunk
|
||||
|
||||
- PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}
|
||||
- Content-Type: application/octet-stream
|
||||
|
||||
Response returns updated upload status envelope.
|
||||
|
||||
### Complete Upload
|
||||
|
||||
- POST /api/admin/media/uploads/{uploadId}/complete
|
||||
|
||||
On success returns admin media listing payload (files + generatedAt).
|
||||
|
||||
## Metrics and Health
|
||||
|
||||
### Chat metrics
|
||||
|
||||
- GET /metrics
|
||||
|
||||
Prometheus exposition includes connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, and build info.
|
||||
|
||||
### Health
|
||||
|
||||
- GET /health on chat service
|
||||
- GET /health on playout service
|
||||
- GET /health on nginx reverse proxy
|
||||
|
||||
## HTTP Status Conventions
|
||||
|
||||
- 200/201: successful request
|
||||
- 400: malformed payload, invalid input, missing required fields
|
||||
- 401: unauthorized admin request
|
||||
- 404: not found
|
||||
- 405: method not allowed
|
||||
- 409: logical conflict (for example, mismatched resumed upload)
|
||||
- 413: payload too large
|
||||
- 500: internal server error
|
||||
+76
-1
@@ -1,8 +1,83 @@
|
||||
# Change Log for linearvoid
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
## [1.0.6] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- `GET /api/admin/chat-log` endpoint with `?room=`, `?limit=`, `?before=`, `?username=`, `?text=` filter/pagination support.
|
||||
- `GET /api/admin/chat-log/:id` endpoint returning a single chat log entry as JSON.
|
||||
- `DELETE /api/admin/chat-log/:id` endpoint returning 204 No Content on success.
|
||||
- `DELETE /api/admin/chat-log/bulk` endpoint accepting `{"ids": [...]}` for bulk deletion (max 500).
|
||||
- Chat Log admin tab with table, individual delete with confirmation dialog, pagination (prev/next), search/filter by room/username/text, select-all checkbox, and bulk delete button.
|
||||
- Added `"lint": "tsc --noEmit"` script to `frontend/package.json`.
|
||||
- `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion.
|
||||
- Resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
|
||||
- Prometheus-format `/metrics` endpoint for chat runtime and admin-session monitoring.
|
||||
- Dedicated operational documentation: `API.md`, `DEPLOYMENT.md`, `MONITORING.md`, `SECURITY.md`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Split `frontend/admin/index.html` into a template shell plus 9 per-tab page HTML files loaded dynamically via `loadPageContent` / `switchTab` / `bindPageEvents`.
|
||||
- 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 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.
|
||||
- Removed Tailwind and Alpine runtime/build dependencies; eliminated Tailwind directives/warnings during production build.
|
||||
|
||||
## [1.0.3] - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added Prometheus-format `/metrics` endpoint for chat runtime and admin-session monitoring.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Prometheus scrape targets and Grafana dashboard queries to use live chat metrics.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed nginx routing so `/metrics` is reachable through localhost and `linearvoid.tv`.
|
||||
|
||||
## [1.0.2] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated local environment documentation and examples to prefer real-domain local routing through `linearvoid.tv` and compose-safe internal service hostnames.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed admin production build output and nginx routing so `/admin/` serves its own entrypoint.
|
||||
- Fixed static asset serving so hashed JS/CSS files return correct MIME types and stale asset URLs return `404` instead of SPA HTML.
|
||||
- Fixed chat database configuration so `DATABASE_URL` is honored for compose-safe Postgres or SQLite local development.
|
||||
|
||||
## [1.0.1] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Expanded the setup guide with current `.env.example` variables, development prerequisites, Cloudflare R2 setup, Gitea registry setup, and a production deployment guide.
|
||||
- Aligned frontend, nginx, and deployment docs with the `linearvoid.tv` app domain and `media.linearvoid.tv` HLS domain model.
|
||||
|
||||
## [1.0.0] - 2026-08-02
|
||||
|
||||
**Full Backend and Frontend Implementation Complete**
|
||||
### Added
|
||||
|
||||
Full backend and frontend implementation complete.
|
||||
|
||||
- **Playout Service** (`backend/playout/`): Go-based playlist management, ffprobe normalization validation, continuous HLS with immutable segment naming, automatic R2 sync, health check endpoint, and Docker integration (ADR-001, ADR-003).
|
||||
- **Chat Backend** (`backend/chat/`): Go + Gorilla WebSocket, Redis for presence/rate limiting/pub/sub, SQLite persistence for message history and ModerationLog (ADR-006/ADR-007), public rooms, private DM routing, moderation commands with immutable audit logging, health check endpoint, and docker-compose integration.
|
||||
|
||||
+126
-2
@@ -11,16 +11,140 @@ Thank you for considering contributing to Linear Void!
|
||||
5. **Commit Messages**: Reference the relevant ADR (e.g., "feat(playout): add R2 sync per ADR-001").
|
||||
6. **Test with Docker**: Run `docker compose up --build` to test changes.
|
||||
|
||||
## Repository
|
||||
|
||||
- **Primary**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid)
|
||||
- **Container Registry**: `git.allucanget.biz/zwitschi/linearvoid/<service>:latest`
|
||||
- **CI/CD**: Gitea Actions with self-hosted runners (see `.gitea/workflows/`)
|
||||
|
||||
## 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
|
||||
|
||||
- Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
|
||||
- Reference the related ADR in the PR description.
|
||||
- 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!
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide describes a production-style deployment for Linear Void using Docker Compose, Gitea registry images, and Cloudflare fronting.
|
||||
|
||||
## Target Topology
|
||||
|
||||
- Host: Linux server with Docker Engine
|
||||
- Reverse proxy: nginx container (linearvoid-nginx)
|
||||
- Core services: playout, chat, redis, postgres
|
||||
- Observability: prometheus, grafana
|
||||
- CDN/edge: Cloudflare
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine + Docker Compose plugin
|
||||
- Network access to git.allucanget.biz
|
||||
- A valid .env file with production values
|
||||
- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used)
|
||||
|
||||
## Environment File
|
||||
|
||||
Use .env with at least:
|
||||
|
||||
- CI_REGISTRY_URL
|
||||
- CI_REGISTRY_NAMESPACE
|
||||
- CHAT_PORT
|
||||
- NGINX_PORT
|
||||
- DATABASE_URL (if overridden)
|
||||
- REDIS_URL (if overridden)
|
||||
- ADMIN_USERNAME
|
||||
- ADMIN_PASSWORD
|
||||
- VITE_PUBLIC_APP_URL
|
||||
- VITE_PUBLIC_WS_URL
|
||||
- VITE_PUBLIC_HLS_URL
|
||||
- Cloudflare/R2 variables when media sync is enabled
|
||||
|
||||
See .env.example and SETUP.md for full variable reference.
|
||||
|
||||
## 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
|
||||
docker login git.allucanget.biz
|
||||
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
```
|
||||
|
||||
## First-Time or Recovery Provisioning
|
||||
|
||||
1. Ensure host paths exist:
|
||||
|
||||
```bash
|
||||
mkdir -p data/chat data/playlist data/media data/hls
|
||||
```
|
||||
|
||||
1. Confirm volumes and networks:
|
||||
|
||||
```bash
|
||||
docker volume ls
|
||||
docker network ls
|
||||
```
|
||||
|
||||
1. Start stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Health Verification
|
||||
|
||||
After startup, check service status:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
Endpoint checks:
|
||||
|
||||
- App: <http://{host}:8080/>
|
||||
- Nginx health: <http://{host}:8080/health>
|
||||
- Chat health: <http://{host}:3000/health>
|
||||
- Playout health: <http://{host}:8081/health>
|
||||
- Prometheus: <http://{host}:9090>
|
||||
- Grafana: <http://{host}:3001>
|
||||
|
||||
Domain checks:
|
||||
|
||||
- <https://linearvoid.tv>
|
||||
- <https://linearvoid.tv/admin>
|
||||
- <https://linearvoid.tv/metrics>
|
||||
|
||||
## Zero/Low-Downtime Update Pattern
|
||||
|
||||
1. Pull newest images.
|
||||
1. Recreate changed services only:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
|
||||
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||
```
|
||||
|
||||
1. Re-run health checks.
|
||||
|
||||
## Rollback
|
||||
|
||||
If new images are unhealthy:
|
||||
|
||||
1. Pin previous tags in docker-compose.prod.yml.
|
||||
2. Pull pinned tags.
|
||||
3. Restart impacted services.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
|
||||
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||
```
|
||||
|
||||
## Operational Tasks
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
||||
```
|
||||
|
||||
### Restart one service
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml restart chat
|
||||
```
|
||||
|
||||
### Stop stack
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
```
|
||||
|
||||
### Keep volumes (recommended) vs remove volumes (destructive)
|
||||
|
||||
```bash
|
||||
# keep data
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
|
||||
# destructive (remove data)
|
||||
docker compose -f docker-compose.prod.yml down -v
|
||||
```
|
||||
|
||||
## Nginx and Proxy Considerations
|
||||
|
||||
- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx.
|
||||
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
|
||||
|
||||
## Security Baseline
|
||||
|
||||
- Use strong ADMIN_PASSWORD and rotate regularly.
|
||||
- Keep Docker host access restricted.
|
||||
- Prefer HTTPS/WSS through Cloudflare.
|
||||
- Restrict exposed ports if only proxy access is needed.
|
||||
|
||||
## Common Deployment Pitfalls
|
||||
|
||||
- Missing frontend build artifacts in local-only compose workflows.
|
||||
- Wrong registry credentials or missing login.
|
||||
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
|
||||
- Proxy-layer body size limits returning 413 on upload.
|
||||
- Health checks failing before service warmup is complete.
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Monitoring Guide
|
||||
|
||||
This guide covers Prometheus and Grafana setup for Linear Void.
|
||||
|
||||
## Current Monitoring Components
|
||||
|
||||
- Prometheus container: linearvoid-prometheus
|
||||
- Grafana container: linearvoid-grafana
|
||||
- Metrics target: chat service /metrics
|
||||
- Base scrape config: monitoring/prometheus/prometheus.yml
|
||||
|
||||
## Prometheus Configuration
|
||||
|
||||
Current scrape config:
|
||||
|
||||
- Job chat -> chat:3000
|
||||
- Job prometheus -> prometheus:9090
|
||||
- Global scrape interval: 15s
|
||||
|
||||
File:
|
||||
|
||||
- monitoring/prometheus/prometheus.yml
|
||||
|
||||
## Start Monitoring Stack
|
||||
|
||||
With compose stack up:
|
||||
|
||||
- Prometheus UI: <http://localhost:9090>
|
||||
- Grafana UI: <http://localhost:3001>
|
||||
|
||||
Compose services include prometheus and grafana in both docker-compose.yml and docker-compose.prod.yml.
|
||||
|
||||
## Grafana Provisioning
|
||||
|
||||
Current mount paths:
|
||||
|
||||
- Dashboards directory: monitoring/grafana/dashboards
|
||||
- Datasources directory: monitoring/grafana/datasources
|
||||
|
||||
Notes:
|
||||
|
||||
- The dashboards directory includes a Prometheus overview dashboard file.
|
||||
- Datasource provisioning can be done with a YAML file in monitoring/grafana/datasources, or by configuring a datasource in Grafana UI.
|
||||
|
||||
Example datasource provisioning file (create if needed):
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
```
|
||||
|
||||
## Dashboard References
|
||||
|
||||
- Provisioned dashboard: [monitoring/grafana/dashboards/dashboard.json](monitoring/grafana/dashboards/dashboard.json)
|
||||
- Root dashboard snapshot: [monitoring/grafana/dashboard.json](monitoring/grafana/dashboard.json)
|
||||
|
||||
Grafana screenshot:
|
||||
|
||||

|
||||
|
||||
## Key Metrics to Track
|
||||
|
||||
### Chat/Application
|
||||
|
||||
- linearvoid_chat_connected_clients
|
||||
- linearvoid_chat_stored_messages_total
|
||||
- linearvoid_chat_moderation_actions_total
|
||||
- linearvoid_chat_redis_enabled
|
||||
- linearvoid_chat_redis_up
|
||||
- linearvoid_chat_active_admin_sessions
|
||||
|
||||
### Prometheus Runtime
|
||||
|
||||
- up
|
||||
- process_cpu_seconds_total
|
||||
- process_resident_memory_bytes
|
||||
- prometheus_tsdb_head_series
|
||||
- prometheus_tsdb_wal_writes_failed_total
|
||||
- prometheus_tsdb_compactions_failed_total
|
||||
|
||||
## Suggested Alerts
|
||||
|
||||
Create a Prometheus rules file (for example monitoring/prometheus/alerts.yml) and wire it into Prometheus.
|
||||
|
||||
Recommended baseline alerts:
|
||||
|
||||
- Chat target down for 2m
|
||||
- Prometheus target down for 2m
|
||||
- Redis indicator down for 2m
|
||||
- TSDB WAL/compaction failure rate above zero
|
||||
- Elevated handler/query latency p99
|
||||
|
||||
## Validate Monitoring End-to-End
|
||||
|
||||
1. Verify /metrics through nginx:
|
||||
|
||||
- <https://linearvoid.tv/metrics>
|
||||
|
||||
1. Confirm targets in Prometheus:
|
||||
|
||||
- Status -> Targets page in Prometheus UI
|
||||
|
||||
1. Confirm dashboard data in Grafana:
|
||||
|
||||
- Import or open provisioned dashboards
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No chat metrics in Prometheus
|
||||
|
||||
- Check chat container health and logs.
|
||||
- Confirm /metrics is reachable from Prometheus container.
|
||||
- Verify scrape target is chat:3000 in prometheus.yml.
|
||||
|
||||
### Grafana shows no data
|
||||
|
||||
- Ensure datasource points to <http://prometheus:9090>.
|
||||
- Confirm Prometheus is healthy.
|
||||
- Check dashboard query labels match active jobs.
|
||||
|
||||
### High cardinality or query slowness
|
||||
|
||||
- Reduce unneeded labels.
|
||||
- Limit expensive dashboard panels.
|
||||
- Tune retention and query windows.
|
||||
|
||||
## Operational Recommendations
|
||||
|
||||
- Keep alert thresholds tied to actual user impact.
|
||||
- Review dashboards after each release that changes endpoints or metrics names.
|
||||
- Periodically test alert routing and incident response flow.
|
||||
@@ -1,14 +1,25 @@
|
||||
# Linear Void
|
||||
|
||||
A self-managed **24/7 linear video channel** for a primarily US audience. It runs a continuous playlist of self-owned videos with true/near-true synchronization, a custom lightweight viewer SPA (hls.js player + chat), and full independence from third-party platforms (YouTube, Twitch, etc.).
|
||||
[Linear Void](https://linearvoid.tv) is a self-managed **24/7 linear video channel** for a primarily US audience. It runs a continuous playlist of self-owned videos with true/near-true synchronization, a custom lightweight viewer SPA (hls.js player + chat), and full independence from third-party platforms (YouTube, Twitch, etc.).
|
||||
|
||||
**Repository**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid)
|
||||
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||
[](LICENSE)
|
||||
[](https://git.allucanget.biz)
|
||||
[](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.
|
||||
- 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.
|
||||
|
||||
See [`docs/architecture/adr/ADR-001.md`](docs/architecture/adr/ADR-001.md) for the consolidated decision record, storage calculations, cache rules, cost model, and implementation plan.
|
||||
See [`docs/architecture/adr/`](docs/architecture/adr/) for the full set of decision records (ADR-001 through ADR-007) covering architecture, storage calculations, cache rules, cost model, module structure, tech stack, admin interfaces, data storage, and caching strategy.
|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -18,6 +29,23 @@ See [`docs/architecture/adr/ADR-001.md`](docs/architecture/adr/ADR-001.md) for t
|
||||
- Video normalization, monitoring, fallback slate, and easy playlist management.
|
||||
- US-centric performance (Ashburn origin + Cloudflare POPs).
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
V[US Viewers] -->|HTTPS| CF[Cloudflare CDN]
|
||||
CF -->|Cache HIT| R2[Cloudflare R2]
|
||||
CF -->|Cache MISS| N[Nginx :80]
|
||||
N -->|/ws| CH[Chat :3000]
|
||||
N -->|/admin| CH
|
||||
N -->|/hls| P[Playout :8081]
|
||||
N -->|/*| FE[Frontend SPA]
|
||||
CH --> R[(Redis)]
|
||||
CH --> DB[(PostgreSQL)]
|
||||
P -->|ffmpeg| HLS[/data/hls/]
|
||||
P -->|sync| R2
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
@@ -55,7 +83,13 @@ linearvoid/
|
||||
│ └── architecture/
|
||||
│ ├── README.md # arc42 template (fully populated)
|
||||
│ └── adr/
|
||||
│ └── ADR-001.md # Consolidated decision record
|
||||
│ ├── ADR-001.md # Consolidated decision record
|
||||
│ ├── ADR-002-module-structure.md
|
||||
│ ├── ADR-003-tech-stack.md
|
||||
│ ├── ADR-004-admin-video.md
|
||||
│ ├── ADR-005-admin-moderation-analytics.md
|
||||
│ ├── ADR-006-data-storage.md
|
||||
│ └── ADR-007-redis-usage.md
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├── docker-compose.yml # Top-level for dev (playout + chat + nginx)
|
||||
@@ -72,24 +106,81 @@ linearvoid/
|
||||
- **`infrastructure/`**: Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
||||
- **`scripts/`**: One-off and CI-friendly scripts for video preparation, R2 sync, backups, and playlist management.
|
||||
- **`monitoring/`**: Grafana dashboards, Prometheus rules, logging configuration.
|
||||
- **`docs/architecture/`**: Complete arc42 documentation + ADR-001 (the single source of truth for all architectural decisions).
|
||||
- **`docs/architecture/`**: Complete arc42 documentation + ADRs 001-007 (the single source of truth for all architectural decisions).
|
||||
|
||||
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. `cp .env.example .env` and configure secrets (R2 keys, Cloudflare, etc.).
|
||||
2. Prepare normalized videos (`scripts/normalize-video.sh`).
|
||||
3. Run locally: `docker compose up` (or individual services).
|
||||
4. Deploy: Provision Hetzner VPS, push to R2, configure Cloudflare cache rules (see `infrastructure/cloudflare/` and ADR-001).
|
||||
5. Full setup and operational runbooks are in the [architecture docs](docs/architecture/README.md).
|
||||
```bash
|
||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
||||
cd linearvoid
|
||||
cp .env.example .env
|
||||
# Edit .env with your R2 credentials and domain settings
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
- **Viewer**: [https://linearvoid.tv](https://linearvoid.tv) (or `http://localhost`)
|
||||
- **Admin**: [https://linearvoid.tv/admin](https://linearvoid.tv/admin) (or `http://localhost/admin`)
|
||||
- **Metrics**: [https://linearvoid.tv/metrics](https://linearvoid.tv/metrics)
|
||||
|
||||
Full setup guide: [`SETUP.md`](SETUP.md).
|
||||
|
||||
See the [architecture docs](docs/architecture/README.md) for detailed architecture and ADRs.
|
||||
|
||||
## Development
|
||||
|
||||
- Use Docker Compose for local development (playout + chat + frontend).
|
||||
- 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.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------------- | ----------------------------------------------- | -------------------------------- |
|
||||
| `R2_ACCESS_KEY_ID` | — | Cloudflare R2 access key |
|
||||
| `R2_SECRET_ACCESS_KEY` | — | Cloudflare R2 secret key |
|
||||
| `R2_BUCKET_NAME` | `linearvoid-media` | R2 bucket for HLS segments |
|
||||
| `DATABASE_URL` | `postgres://...@db:5432/...` | Chat DB (Postgres or SQLite) |
|
||||
| `REDIS_URL` | `redis://redis:6379/0` | Redis for presence/rate limiting |
|
||||
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` | Public viewer URL |
|
||||
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` | WebSocket endpoint |
|
||||
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
|
||||
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
||||
| `ADMIN_PASSWORD` | — | Admin panel password |
|
||||
| `HLS_SEGMENT_DURATION` | `6` | HLS segment duration in seconds |
|
||||
| `HLS_WINDOW_SIZE` | `24` | HLS playlist window size |
|
||||
|
||||
Full reference: [`.env.example`](.env.example).
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Problem | Check |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------- |
|
||||
| **Playout not starting** | Verify playlist exists in `data/playlist/playlist.txt` and media files are accessible |
|
||||
| **HLS not playing** | Check `docker compose logs playout` for ffmpeg errors; verify `data/hls/` permissions |
|
||||
| **Chat not connecting** | Ensure Redis is running: `docker compose logs redis`; check `DATABASE_URL` |
|
||||
| **Admin panel 401** | Verify `ADMIN_USERNAME`/`ADMIN_PASSWORD` in `.env` match login credentials |
|
||||
| **Docker build fails** | Run `docker compose build --no-cache`; check `backend/playout/.dockerignore` |
|
||||
| **R2 upload failures** | Verify `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`; check bucket CORS settings |
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Service | Endpoint | Description |
|
||||
| ----------- | ------------------------ | -------------------------- |
|
||||
| **Chat** | `GET /health` | Health check |
|
||||
| **Chat** | `GET /metrics` | Prometheus metrics |
|
||||
| **Chat** | `GET /ws` | WebSocket upgrade for chat |
|
||||
| **Chat** | `GET /api/admin/*` | Admin API (auth required) |
|
||||
| **Chat** | `POST /api/admin/login` | Admin login |
|
||||
| **Playout** | `GET /health` | Health check (port 8081) |
|
||||
| **Nginx** | `GET /` | Viewer SPA |
|
||||
| **Nginx** | `GET /admin/` | Admin panel |
|
||||
| **Nginx** | `GET /hls/playlist.m3u8` | HLS manifest |
|
||||
|
||||
## Architecture Documentation
|
||||
|
||||
The architecture is fully documented using the [arc42 template](docs/architecture/README.md) (with populated sections, Mermaid context and building-block diagrams, deployment view, cross-cutting concepts, quality goals, risks, and glossary).
|
||||
@@ -98,10 +189,43 @@ The architecture is fully documented using the [arc42 template](docs/architectur
|
||||
|
||||
- **[ADR-001](docs/architecture/adr/ADR-001.md)**: Overall system architecture, HLS + Cloudflare R2 caching strategy, storage calculations, cost model, risks, and implementation plan.
|
||||
- **[ADR-002](docs/architecture/adr/ADR-002-module-structure.md)**: Chosen project folder/module structure (frontend, backend/playout, backend/chat, infrastructure, scripts, monitoring) and mapping to arc42 building blocks.
|
||||
- **[ADR-003](docs/architecture/adr/ADR-003-tech-stack.md)**: Technology stack, language choices per module (TypeScript for frontend, Go preferred for chat and playout wrapper, Bash/Python for scripts), Docker-first approach, and all implicit assumptions (mandatory video normalization, Cloudflare-centric delivery, monitoring focus, etc.).
|
||||
|
||||
These three ADRs together provide complete traceability for all major design decisions. Future ADRs will cover Docker Compose layout, API contracts, monitoring strategy, and access control.
|
||||
- **[ADR-003](docs/architecture/adr/ADR-003-tech-stack.md)**: Technology stack, language choices per module (TypeScript for frontend, Go preferred for chat and playout wrapper, Bash/Python for scripts), Docker-first approach, and all implicit assumptions.
|
||||
- **[ADR-004](docs/architecture/adr/ADR-004-admin-video.md)**: Video admin web interface (upload, playlist management, schedule overrides, normalization enforcement).
|
||||
- **[ADR-005](docs/architecture/adr/ADR-005-admin-moderation-analytics.md)**: Web admin interfaces for user management, moderation dashboard, analytics, and system configuration.
|
||||
- **[ADR-006](docs/architecture/adr/ADR-006-data-storage.md)**: Data storage strategy (SQLite vs PostgreSQL for v1, migration path, admin interface capabilities).
|
||||
- **[ADR-007](docs/architecture/adr/ADR-007-redis-usage.md)**: Redis usage (exact use cases, TTL policies, persistence, fallback behavior, admin configuration).
|
||||
|
||||
See the [architecture README](docs/architecture/README.md) for the big-picture view and the individual ADR files for detailed rationale, alternatives, and consequences.
|
||||
|
||||
Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE) should reference the architecture documentation.
|
||||
|
||||
## Resources
|
||||
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
||||
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||
- [API.md](API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
|
||||
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||
|
||||
**CI/CD**: [View workflows](https://git.allucanget.biz/zwitschi/linearvoid/actions) on Gitea Actions.
|
||||
|
||||
**Container Registry**: `git.allucanget.biz/zwitschi/linearvoid/<service>:latest`
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines. Quick overview:
|
||||
|
||||
1. Read the [architecture docs](docs/architecture/README.md) and ADRs first.
|
||||
2. Plan tasks in `.github/instructions/TODO.md`.
|
||||
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 (1.0.3, 1.0.4, etc.) and automatically built/pushed to the container registry.
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions.
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Security Guide
|
||||
|
||||
This document summarizes security controls and best practices for Linear Void.
|
||||
|
||||
## Security Model Overview
|
||||
|
||||
Security is layered across:
|
||||
|
||||
- Cloudflare edge
|
||||
- nginx reverse proxy
|
||||
- chat/admin backend
|
||||
- container and host operations
|
||||
|
||||
## Authentication and Sessions
|
||||
|
||||
Admin access is session-cookie based.
|
||||
|
||||
- Login endpoint: /api/admin/login
|
||||
- Session endpoint: /api/admin/session
|
||||
- Logout endpoint: /api/admin/logout
|
||||
- Cookie: linearvoid_admin_session
|
||||
- Cookie flags: HttpOnly, SameSite=Strict, Secure over HTTPS
|
||||
- Session expiry enforced server-side
|
||||
|
||||
Best practices:
|
||||
|
||||
- Set strong ADMIN_USERNAME and ADMIN_PASSWORD values.
|
||||
- Rotate admin credentials periodically.
|
||||
- Keep ADMIN_SESSION_TTL_HOURS aligned with operational policy.
|
||||
|
||||
## Transport Security
|
||||
|
||||
- Enforce HTTPS for app/admin endpoints.
|
||||
- Use WSS for chat WebSocket.
|
||||
- Keep Cloudflare TLS mode strict and valid at origin.
|
||||
|
||||
## Reverse Proxy Controls
|
||||
|
||||
nginx controls include:
|
||||
|
||||
- auth_request guard for /admin and /api/admin paths
|
||||
- dedicated unauthorized behavior for admin API responses
|
||||
- explicit websocket upgrade headers for /ws
|
||||
- upload body-size limits for resumable media chunk uploads
|
||||
|
||||
Best practices:
|
||||
|
||||
- Keep proxy body-size and timeout settings aligned across all proxy layers.
|
||||
- Avoid exposing backend service ports publicly unless required.
|
||||
|
||||
## Input Validation and Upload Safety
|
||||
|
||||
Server-side protections include:
|
||||
|
||||
- extension allow-list for media uploads
|
||||
- MIME checks
|
||||
- file-size ceiling (2GB)
|
||||
- resumable chunk bounds and chunk-index validation
|
||||
- media path safety checks on delete
|
||||
|
||||
Best practices:
|
||||
|
||||
- Keep upload chunk size bounded.
|
||||
- Reject unsupported content types consistently.
|
||||
- Monitor 4xx/5xx rates on upload routes.
|
||||
|
||||
## Rate Limiting and Abuse Controls
|
||||
|
||||
- Chat rate limiting uses Redis INCR + EXPIRE windows.
|
||||
- Current baseline: more than 10 messages in 10 seconds is throttled.
|
||||
- Moderation actions are logged for auditability.
|
||||
|
||||
Best practices:
|
||||
|
||||
- Tune thresholds by observed abuse patterns.
|
||||
- Keep moderation logs reviewed and retained per policy.
|
||||
|
||||
## Infrastructure and Secrets
|
||||
|
||||
- Store secrets in .env (not in committed source).
|
||||
- Use Gitea Actions secrets for CI/CD credentials and admin credentials.
|
||||
- Restrict Docker host control plane exposure.
|
||||
- Limit who can read deployment environment files.
|
||||
|
||||
## Cloudflare and Edge Security
|
||||
|
||||
Recommended baseline:
|
||||
|
||||
- WAF enabled
|
||||
- Rate limits on sensitive paths
|
||||
- Bot protection where appropriate
|
||||
- Cache rules scoped carefully to avoid caching private/control-plane content
|
||||
|
||||
## Data Protection and Retention
|
||||
|
||||
- Persistent data: postgres, redis append-only, chat state, playlist/media metadata
|
||||
- Backup and restore drills should be periodic
|
||||
- Restore procedures should be tested in isolated environments
|
||||
|
||||
## Logging and Monitoring for Security
|
||||
|
||||
Track and alert on:
|
||||
|
||||
- repeated failed admin login attempts
|
||||
- spikes in 401 and 403 responses
|
||||
- sudden upload errors (413/400/500 patterns)
|
||||
- unusual websocket disconnect or message-rate anomalies
|
||||
|
||||
## Hardening Checklist
|
||||
|
||||
- [ ] HTTPS/WSS enforced end-to-end
|
||||
- [ ] Strong admin credentials configured
|
||||
- [ ] Secrets only in env/secrets stores
|
||||
- [ ] Proxy auth and websocket headers validated
|
||||
- [ ] Upload limits and validation active
|
||||
- [ ] Rate limiting active and tested
|
||||
- [ ] Cloudflare WAF/rate-limit rules active
|
||||
- [ ] Backup/restore drills completed
|
||||
- [ ] Security-related monitoring alerts routed
|
||||
|
||||
## Incident Response Baseline
|
||||
|
||||
1. Detect issue (alerts/logs/user reports).
|
||||
2. Triage scope (edge, proxy, app, data plane).
|
||||
3. Contain impact (rate limit, credential rotate, route disable).
|
||||
4. Recover services and verify health.
|
||||
5. Record timeline and corrective actions.
|
||||
@@ -1,22 +1,41 @@
|
||||
# Linear Void Setup Guide
|
||||
|
||||
This guide covers prerequisites, environment setup, running the application locally and in production, and troubleshooting. It references the architecture documentation (`docs/architecture/README.md`) and ADRs in `docs/architecture/adr/`.
|
||||
This guide covers prerequisites, environment setup, running the application locally and in production, and troubleshooting. It references the [architecture documentation](./docs/architecture/README.md)(`docs/architecture/README.md`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker & Docker Compose**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com).
|
||||
- **Node.js 20+**: For frontend development (`cd frontend && npm run dev`).
|
||||
- **Node.js 20+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
|
||||
- **npm**: Installed with Node.js and used for the frontend dependency installation and Vite workflow.
|
||||
- **Go 1.23+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`).
|
||||
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows).
|
||||
- **Git**: For cloning and version control.
|
||||
- **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases).
|
||||
- **Gitea Account**: Required if you want to push images to the self-hosted registry or use the repository's CI/CD flow.
|
||||
- Set up the required Actions secrets in Gitea at: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
|
||||
- Add the following secrets:
|
||||
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`)
|
||||
- `CI_REGISTRY_TOKEN`: A Gitea Personal Access Token with `write:packages` scope
|
||||
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`)
|
||||
- `ADMIN_PASSWORD`: A strong password for the production admin panel
|
||||
- `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).
|
||||
|
||||
## Development Prerequisites
|
||||
|
||||
- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`.
|
||||
- **Docker build support**: Local development expects Docker to be able to build the `chat`, `playout`, and `nginx` images referenced in `docker-compose.yml`.
|
||||
- **Data directories**: Create `data/playlist`, `data/media`, `data/hls`, and `data/chat` before the first run so bind mounts resolve cleanly.
|
||||
- **Database choice**: The stack defaults to PostgreSQL in `docker-compose.yml`. If you want SQLite for local-only development, update `DATABASE_URL` in `.env` accordingly.
|
||||
- **Redis availability**: Chat presence, rate limiting, and pub/sub require Redis. The compose stack provides it automatically.
|
||||
- **Video inputs**: Source videos should be normalized before playout. If the playlist contains invalid files, the playout container will fail its validation flow.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
1. **Clone the Repository** (if not already done):
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
||||
cd linearvoid
|
||||
```
|
||||
|
||||
@@ -34,39 +53,148 @@ This guide covers prerequisites, environment setup, running the application loca
|
||||
R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
||||
R2_BUCKET_NAME=linearvoid-media
|
||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
||||
R2_REGION=auto
|
||||
|
||||
# Cloudflare (for custom domain and cache rules)
|
||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
||||
MEDIA_DOMAIN=media.example.com
|
||||
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||
MEDIA_DOMAIN=media.linearvoid.tv
|
||||
|
||||
# Public frontend URLs
|
||||
# Preferred local setup uses the real public domains through Cloudflare and Nginx Proxy Manager.
|
||||
VITE_PUBLIC_APP_URL=https://linearvoid.tv
|
||||
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
|
||||
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin
|
||||
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main
|
||||
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
ADMIN_SESSION_TTL_HOURS=12
|
||||
# Optional direct-local fallback when bypassing the public proxy:
|
||||
# VITE_PUBLIC_APP_URL=http://localhost
|
||||
# VITE_PUBLIC_WS_URL=ws://localhost/ws
|
||||
# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8
|
||||
|
||||
# Gitea Container Registry (for self-hosted Docker images)
|
||||
CI_REGISTRY_URL=git.allucanget.biz
|
||||
CI_REGISTRY_USER=your-gitea-username
|
||||
CI_REGISTRY_TOKEN=your-gitea-token
|
||||
CI_REGISTRY_NAMESPACE=zwitschi/linearvoid
|
||||
# Gitea API access (optional, for CI/release automation)
|
||||
CI_SERVER_URL=https://git.allucanget.biz
|
||||
|
||||
# Chat / Database
|
||||
DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
|
||||
# or for SQLite-only local development:
|
||||
# DATABASE_URL=sqlite://./data/chat.db
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
DATABASE_URL=sqlite://./data/chat.db # or postgres://user:pass@db:5432/linearvoid
|
||||
|
||||
# Ports (for local development)
|
||||
PLAYOUT_PORT=8080
|
||||
CHAT_PORT=3000
|
||||
NGINX_PORT=8080
|
||||
PLAYOUT_HEALTH_PORT=8081
|
||||
NGINX_PORT=80
|
||||
|
||||
# Playout Configuration
|
||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||
PLAYLIST_PATH=/app/playlist.txt
|
||||
HLS_SEGMENT_DURATION=6
|
||||
HLS_WINDOW_SIZE=24
|
||||
|
||||
# Logging
|
||||
# Logging / Monitoring
|
||||
LOG_LEVEL=info
|
||||
PROMETHEUS_PORT=9090
|
||||
GRAFANA_PORT=3001
|
||||
|
||||
# Development flags
|
||||
NODE_ENV=development
|
||||
GO_ENV=development
|
||||
```
|
||||
|
||||
4. **Create Data Directories**:
|
||||
4. **Check local value overrides for Docker Compose**:
|
||||
- `docker-compose.yml` builds images locally and maps the frontend build output from `frontend/dist` into nginx.
|
||||
- `docker-compose.prod.yml` pulls prebuilt images from the Gitea registry.
|
||||
- Internal container-to-container URLs should use compose hostnames, for example `REDIS_URL=redis://redis:6379/0` and `DATABASE_URL=postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`.
|
||||
- For production public routing, set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
||||
|
||||
5. **Create Data Directories**:
|
||||
|
||||
```bash
|
||||
mkdir -p data/playlist data/media data/hls data/chat
|
||||
```
|
||||
|
||||
5. **Prepare Sample Videos** (optional):
|
||||
6. **Prepare Sample Videos** (optional):
|
||||
- Place normalized MP4 files in `data/media/`.
|
||||
- Create a `data/playlist/playlist.txt` with paths (see ADR-001 for normalization requirements).
|
||||
|
||||
## Cloudflare R2 Setup
|
||||
|
||||
1. **Create an R2 bucket**:
|
||||
- Create a bucket such as `linearvoid-media` in Cloudflare R2.
|
||||
- Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`.
|
||||
|
||||
2. **Generate R2 API credentials**:
|
||||
- Create an access key pair with read/write permissions for the bucket.
|
||||
- Set `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_REGION=auto` in `.env`.
|
||||
|
||||
3. **Find the S3 endpoint**:
|
||||
- Use the account-scoped endpoint format `https://<account-id>.r2.cloudflarestorage.com`.
|
||||
- Store the account ID in both `R2_ENDPOINT` and `CLOUDFLARE_ACCOUNT_ID` where applicable.
|
||||
|
||||
4. **Bind a custom media domain**:
|
||||
- Attach a domain such as `media.linearvoid.tv` to the bucket.
|
||||
- Set `MEDIA_DOMAIN` to that hostname.
|
||||
|
||||
5. **Configure app-domain DNS and TLS**:
|
||||
- Point `linearvoid.tv` to the public entrypoint serving nginx.
|
||||
- Point `www.linearvoid.tv` to the same target and redirect it to `https://linearvoid.tv`.
|
||||
- Keep `media.linearvoid.tv` on the R2 custom domain path so HLS delivery stays split from the app origin.
|
||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
||||
|
||||
6. **Apply cache behavior**:
|
||||
- Configure the `.ts` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||
- Enable Smart Tiered Cache for the R2-backed hostname.
|
||||
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
|
||||
|
||||
7. **Verify access**:
|
||||
- Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url <R2_ENDPOINT>`.
|
||||
- After deployment, check Cloudflare analytics for cache hit ratio and origin request volume.
|
||||
|
||||
## Gitea Setup
|
||||
|
||||
1. **Create or verify a Gitea user**:
|
||||
- The user should have access to the repository and permission to read from the container registry.
|
||||
|
||||
2. **Create an access token**:
|
||||
- Generate a token with the scopes needed for package/registry access and, if applicable, repository automation.
|
||||
- Store it as `CI_REGISTRY_TOKEN` in `.env`.
|
||||
|
||||
3. **Configure Gitea Actions Secrets**:
|
||||
- Go to the repository settings page: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
|
||||
- Add the following secrets to enable automated CI/CD and production deployment:
|
||||
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`).
|
||||
- `CI_REGISTRY_TOKEN`: Your Gitea Personal Access Token with `write:packages` scope.
|
||||
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`).
|
||||
- `ADMIN_PASSWORD`: A strong password for the production admin panel.
|
||||
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`).
|
||||
|
||||
4. **Configure registry variables**:
|
||||
- Set `CI_REGISTRY_URL` to the Gitea host.
|
||||
- Set `CI_REGISTRY_NAMESPACE` to the image namespace, for example `zwitschi/linearvoid`.
|
||||
- Set `CI_REGISTRY_USER` to the account that will push or pull images.
|
||||
|
||||
5. **Authenticate Docker to the registry**:
|
||||
|
||||
```bash
|
||||
docker login git.allucanget.biz
|
||||
```
|
||||
|
||||
6. **Build and publish images when needed**:
|
||||
- Local development uses `docker-compose.yml`, which builds from source.
|
||||
- Production uses `docker-compose.prod.yml`, which expects the `playout`, `chat`, and `nginx` images to exist in the configured Gitea registry namespace.
|
||||
|
||||
7. **Validate image availability**:
|
||||
- Before a production rollout, verify that the expected `:latest` tags can be pulled from the registry on the target server.
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Local Development
|
||||
@@ -78,9 +206,10 @@ This guide covers prerequisites, environment setup, running the application loca
|
||||
```
|
||||
|
||||
2. **Access the Application**:
|
||||
- Viewer: <http://localhost:8080>
|
||||
- Chat WebSocket: ws://localhost:3000/ws
|
||||
- Admin Interface: <http://localhost:8080/admin> (use the credentials from `.env` or default admin/admin)
|
||||
- Preferred local access with the documented proxy setup: <https://linearvoid.tv>
|
||||
- Admin Interface: <https://linearvoid.tv/admin> (redirects to `/admin/login/` until authenticated)
|
||||
- Chat WebSocket: `wss://linearvoid.tv/ws`
|
||||
- Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws`
|
||||
- Health Checks:
|
||||
- Playout: <http://localhost:8081/health>
|
||||
- Chat: <http://localhost:3000/health>
|
||||
@@ -92,27 +221,189 @@ This guide covers prerequisites, environment setup, running the application loca
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev server runs on <http://localhost:5173>. It proxies API calls to the backend services.
|
||||
|
||||
4. **Stop Services**:
|
||||
4. **Build frontend assets for nginx-backed local runs**:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
The local nginx container serves `frontend/dist`, so rebuilding the frontend is required before validating production-like static output.
|
||||
|
||||
5. **Stop Services**:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Local Test Environment Deployment (Remote Docker Host)
|
||||
|
||||
For testing deployments on a local network server without setting up a full production VPS, you can deploy directly to a remote Docker host (e.g., a home lab server or test machine) using Docker's TCP socket.
|
||||
|
||||
#### Test Environment Prerequisites
|
||||
|
||||
- A remote server running Docker at IP `192.168.88.40` (running Alpine Linux).
|
||||
- Docker TCP socket enabled and listening on port `2375` on the remote server.
|
||||
|
||||
##### Enabling Docker TCP Socket on Alpine Linux
|
||||
|
||||
To allow the Gitea Actions runner (or your local machine) to deploy to the remote Alpine host, you must configure the Docker daemon to listen on the TCP socket:
|
||||
|
||||
1. Edit `/etc/conf.d/docker` on the Alpine host:
|
||||
|
||||
```bash
|
||||
vi /etc/conf.d/docker
|
||||
```
|
||||
|
||||
2. Update the `DOCKER_OPTS` line to include both the unix socket and the TCP socket:
|
||||
|
||||
```bash
|
||||
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
|
||||
```
|
||||
|
||||
3. Restart the Docker service:
|
||||
|
||||
```bash
|
||||
rc-service docker restart
|
||||
```
|
||||
|
||||
4. Verify that Docker is listening on port `2375`:
|
||||
|
||||
```bash
|
||||
netstat -tlnp | grep 2375
|
||||
```
|
||||
|
||||
5. Ensure any firewall (like `awall` or `iptables`) on the Alpine host allows incoming traffic on port `2375` from your local network subnet (e.g., `192.168.88.0/24`).
|
||||
|
||||
#### Deployment Steps
|
||||
|
||||
1. **Configure Docker CLI to use the Remote Host**:
|
||||
You can point your local Docker CLI to the remote server by setting the `DOCKER_HOST` environment variable:
|
||||
- **PowerShell (Windows)**:
|
||||
|
||||
```powershell
|
||||
$env:DOCKER_HOST="tcp://192.168.88.40:2375"
|
||||
```
|
||||
|
||||
- **Bash (Linux/macOS)**:
|
||||
|
||||
```bash
|
||||
export DOCKER_HOST="tcp://192.168.88.40:2375"
|
||||
```
|
||||
|
||||
2. **Verify Connection**:
|
||||
Run `docker info` to confirm your local CLI is communicating with the remote server at `192.168.88.40`:
|
||||
|
||||
```bash
|
||||
docker info
|
||||
```
|
||||
|
||||
The output should show the remote server's hostname and system details.
|
||||
|
||||
3. **Deploy the Stack**:
|
||||
With `DOCKER_HOST` set, any `docker` or `docker compose` commands you run locally will execute directly on the remote server.
|
||||
- **Deploy from Registry (Production-like)**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
```
|
||||
|
||||
- **Build and Deploy from Source**:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
4. **Access the Test Environment**:
|
||||
- Playout Health: `http://192.168.88.40:8081/health`
|
||||
- Chat Health: `http://192.168.88.40:3000/health`
|
||||
- Nginx Entrypoint: `http://192.168.88.40:8080` (or the port mapped to `NGINX_PORT` in `.env`)
|
||||
|
||||
5. **Reset to Local Docker Host**:
|
||||
Once testing is complete, clear the `DOCKER_HOST` variable to point back to your local machine:
|
||||
- **PowerShell**:
|
||||
|
||||
```powershell
|
||||
Remove-Item env:DOCKER_HOST
|
||||
```
|
||||
|
||||
- **Bash**:
|
||||
|
||||
```bash
|
||||
unset DOCKER_HOST
|
||||
```
|
||||
|
||||
### Production Setup Guide
|
||||
|
||||
1. **Prepare the target host**:
|
||||
- Provision a Linux VPS with Docker Engine, Docker Compose, Git, and enough disk for media, HLS output, PostgreSQL, Redis persistence, and monitoring data.
|
||||
- Open the ports required for HTTP/HTTPS and any restricted monitoring access you intend to expose.
|
||||
|
||||
2. **Clone the repository and create the environment file**:
|
||||
|
||||
```bash
|
||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
||||
cd linearvoid
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. **Set production-safe `.env` values**:
|
||||
- Replace placeholder secrets.
|
||||
- Use compose-network hostnames for internal services, for example `redis` and `db`.
|
||||
- Set strong values for `POSTGRES_PASSWORD` and `GRAFANA_PASSWORD`.
|
||||
- Keep `MEDIA_DOMAIN`, R2 credentials, and Gitea registry values consistent with the external infrastructure.
|
||||
- Set `MEDIA_DOMAIN=media.linearvoid.tv`.
|
||||
- Set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
||||
- If you intentionally bypass the public proxy for local-only testing, switch those values to `http://localhost`, `ws://localhost/ws`, and `http://localhost/hls/playlist.m3u8`.
|
||||
|
||||
4. **Create runtime directories on the server**:
|
||||
|
||||
```bash
|
||||
mkdir -p data/playlist data/media data/hls data/chat
|
||||
```
|
||||
|
||||
5. **Publish or verify required images in Gitea**:
|
||||
- Ensure the `playout`, `chat`, and `nginx` images exist under `${CI_REGISTRY_URL}/${CI_REGISTRY_NAMESPACE}`.
|
||||
- Log in to the registry from the VPS before pulling.
|
||||
|
||||
6. **Deploy the production stack**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
7. **Run post-deploy verification**:
|
||||
- Check `docker compose -f docker-compose.prod.yml ps`.
|
||||
- Confirm nginx, chat, and playout health endpoints respond.
|
||||
- Verify that HLS output is being written to `data/hls` and that the media domain serves manifests and segments through Cloudflare.
|
||||
|
||||
8. **Harden and operate the deployment**:
|
||||
- Put HTTPS in front of nginx using Cloudflare or another reverse proxy, with `linearvoid.tv` as canonical app host and `www.linearvoid.tv` redirected.
|
||||
- Restrict Grafana and Prometheus exposure to trusted IPs or a private network.
|
||||
- Run the stack under systemd or another service manager if you need auto-start behavior outside Docker restart policies.
|
||||
|
||||
### Production Deployment (Hetzner VPS + Cloudflare)
|
||||
|
||||
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
||||
2. **Configure Cloudflare**:
|
||||
- Create R2 bucket with `enam` location hint.
|
||||
- Set up custom domain (`media.example.com`) with the cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||
- Set up custom media domain (`media.linearvoid.tv`) with the cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||
- Point `linearvoid.tv` and `www.linearvoid.tv` at the app entrypoint, then redirect `www` to the apex domain.
|
||||
- Use Full (strict) TLS for both app and media hostnames.
|
||||
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
|
||||
3. **Deploy**:
|
||||
- Copy the project to the VPS.
|
||||
- Run `docker compose up -d --build`.
|
||||
- Use production compose: `docker compose -f docker-compose.prod.yml up -d`
|
||||
- Or pull from Gitea registry: `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`
|
||||
- Configure systemd to manage the Docker Compose stack.
|
||||
4. **Monitoring**: Access Grafana at the exposed port for dashboards (included in `monitoring/grafana/dashboard.json`).
|
||||
|
||||
@@ -125,7 +416,8 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
|
||||
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
|
||||
- **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
|
||||
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
|
||||
- **Admin Interface**: Ensure the protected route is configured in nginx and authentication is set up (default is open for local development).
|
||||
- **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
|
||||
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
|
||||
|
||||
## Further Reading
|
||||
|
||||
@@ -136,4 +428,4 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
|
||||
|
||||
The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance.
|
||||
|
||||
**Last Updated**: 2026-08-02
|
||||
**Last Updated**: 2026-08-03
|
||||
@@ -1,17 +1,17 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod ./
|
||||
COPY src/ ./src/
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /chat-server ./src/main.go
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk --no-cache add ca-certificates
|
||||
RUN apk --no-cache add ca-certificates curl ffmpeg
|
||||
WORKDIR /app
|
||||
COPY --from=builder /chat-server .
|
||||
COPY config/ ./config/
|
||||
COPY --from=builder /chat-server /usr/local/bin/chat-server
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["./chat-server"]
|
||||
CMD ["/usr/local/bin/chat-server"]
|
||||
+10
-2
@@ -1,10 +1,11 @@
|
||||
module chat
|
||||
|
||||
go 1.23
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
@@ -12,7 +13,14 @@ require (
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
)
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
+1938
-18
File diff suppressed because it is too large.
Load diff
+11
-13
@@ -1,8 +1,7 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/ ./src/
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Initialize Go module if not present
|
||||
RUN go mod init playout || true
|
||||
@@ -17,20 +16,19 @@ RUN apk add --no-cache \
|
||||
ffmpeg \
|
||||
curl \
|
||||
bash
|
||||
|
||||
# Set working directory for playout
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /playlist-manager /app/playlist-manager
|
||||
COPY start-playout.sh /app/
|
||||
COPY *.sh /app/
|
||||
|
||||
RUN chmod +x /app/*.sh /app/playlist-manager
|
||||
|
||||
# Create directories for playlist, media, and HLS output
|
||||
RUN mkdir -p /app/playlist /app/media /app/hls
|
||||
|
||||
RUN mkdir -p /app/hls /app/media /app/playlists
|
||||
# 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
|
||||
COPY demodata/ /app/
|
||||
# Copy the built playlist manager binary from the builder stage
|
||||
COPY --from=builder /playlist-manager /app/playlist-manager
|
||||
# Set execute permissions for the start script and playlist manager binary
|
||||
RUN chmod +x /app/start-playout.sh /app/playlist-manager
|
||||
# Expose HLS directory (will be served by nginx)
|
||||
VOLUME ["/app/hls"]
|
||||
|
||||
# Default command runs the playout loop (which calls the Go validator)
|
||||
CMD ["./start-playout.sh"]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
# Linear Void Demo Playlist
|
||||
file '/app/media/demo1.mp4'
|
||||
file '/app/media/demo2.mp4'
|
||||
@@ -64,6 +64,12 @@ func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "file ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
|
||||
line = strings.Trim(line, "'\"")
|
||||
}
|
||||
|
||||
videos = append(videos, VideoFile{Path: line})
|
||||
}
|
||||
|
||||
@@ -75,7 +81,7 @@ func ValidateVideo(videoPath string) (VideoFile, error) {
|
||||
v := VideoFile{Path: videoPath}
|
||||
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
|
||||
output, err := cmd.Output()
|
||||
_, err := cmd.Output()
|
||||
if err != nil {
|
||||
v.Validation = "ffprobe failed: " + err.Error()
|
||||
return v, err
|
||||
@@ -137,6 +143,46 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"status":"healthy","service":"playout","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
||||
}
|
||||
|
||||
func resetHLSOutput(hlsDir string) error {
|
||||
if err := os.MkdirAll(hlsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.ts"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, match := range matches {
|
||||
if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmp4Matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, match := range fmp4Matches {
|
||||
if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
|
||||
if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
initPath := filepath.Join(hlsDir, "init.mp4")
|
||||
if err := os.Remove(initPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
playlistPath := "/app/playlist/playlist.txt"
|
||||
if len(os.Args) > 1 {
|
||||
@@ -170,15 +216,29 @@ func main() {
|
||||
|
||||
// Run FFmpeg in a loop for continuous playback with R2 sync hook
|
||||
for {
|
||||
fmt.Printf("[%s] Starting FFmpeg playout with -c copy (immutable segments for Cloudflare caching)...\n", time.Now().Format(time.RFC3339))
|
||||
if err := resetHLSOutput("/app/hls"); err != nil {
|
||||
fmt.Println("Failed to reset HLS output:", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] Starting FFmpeg playout with browser-safe H.264/AAC HLS encoding...\n", time.Now().Format(time.RFC3339))
|
||||
|
||||
cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath,
|
||||
"-c:v", "copy", "-c:a", "copy",
|
||||
"-map", "0:v:0", "-map", "0:a:0?",
|
||||
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
|
||||
"-profile:v", "high", "-level", "4.0",
|
||||
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
||||
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
|
||||
"-f", "hls",
|
||||
"-hls_time", "6",
|
||||
"-hls_list_size", "24",
|
||||
"-hls_flags", "delete_segments+append_list+independent_segments",
|
||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S_%%03d.ts", // unique immutable names per ADR-001
|
||||
"-hls_segment_type", "fmp4",
|
||||
"-hls_fmp4_init_filename", "init.mp4",
|
||||
"-strftime", "1",
|
||||
"-hls_flags", "delete_segments+independent_segments",
|
||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
|
||||
"/app/hls/playlist.m3u8")
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
@@ -196,7 +256,7 @@ func main() {
|
||||
|
||||
// syncLatestToR2 uploads new segments and the playlist manifest to R2
|
||||
func syncLatestToR2(hlsDir string) error {
|
||||
files, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.ts"))
|
||||
files, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -215,6 +275,10 @@ func syncLatestToR2(hlsDir string) error {
|
||||
if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil {
|
||||
return err
|
||||
}
|
||||
initPath := filepath.Join(hlsDir, "init.mp4")
|
||||
if err := SyncToR2(initPath, "live/init.mp4"); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Synced live playlist.m3u8 to R2")
|
||||
|
||||
return nil
|
||||
|
||||
@@ -26,16 +26,27 @@ fi
|
||||
|
||||
echo "Playlist validated successfully. Starting continuous HLS playout..."
|
||||
|
||||
mkdir -p /app/hls
|
||||
rm -f /app/hls/playlist.m3u8 /app/hls/init.mp4 /app/hls/segment_*.ts /app/hls/segment_*.m4s
|
||||
|
||||
# Run FFmpeg in a loop for continuous playback
|
||||
while true; do
|
||||
echo "[$(date)] Starting FFmpeg playout with -c copy (stream copy mode)..."
|
||||
echo "[$(date)] Starting FFmpeg playout with browser-safe HLS encoding..."
|
||||
ffmpeg -re -f concat -safe 0 -i "$PLAYLIST_PATH" \
|
||||
-c:v copy -c:a copy \
|
||||
-map 0:v:0 -map 0:a:0? \
|
||||
-vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p" \
|
||||
-c:v libx264 -preset veryfast -pix_fmt yuv420p \
|
||||
-profile:v high -level 4.0 \
|
||||
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||
-c:a aac -ar 48000 -ac 2 -b:a 128k \
|
||||
-f hls \
|
||||
-hls_time "${HLS_SEGMENT_DURATION}" \
|
||||
-hls_list_size "${HLS_WINDOW_SIZE}" \
|
||||
-hls_flags delete_segments+append_list \
|
||||
-hls_segment_filename "/app/hls/segment_%03d.ts" \
|
||||
-hls_segment_type fmp4 \
|
||||
-hls_fmp4_init_filename init.mp4 \
|
||||
-strftime 1 \
|
||||
-hls_flags delete_segments+independent_segments \
|
||||
-hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.m4s" \
|
||||
"/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..."
|
||||
|
||||
echo "Playout loop crashed or ended. Restarting in 5 seconds..."
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
services:
|
||||
playout:
|
||||
image: git.allucanget.biz/zwitschi/linearvoid/playout:latest
|
||||
container_name: linearvoid-playout
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:ro
|
||||
- ./data/hls:/app/hls:rw
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
chat:
|
||||
image: git.allucanget.biz/zwitschi/linearvoid/chat:latest
|
||||
container_name: linearvoid-chat
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${CHAT_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./data/chat:/app:rw
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:rw
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
nginx:
|
||||
image: git.allucanget.biz/zwitschi/linearvoid/nginx:latest
|
||||
container_name: linearvoid-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NGINX_PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./data/hls:/srv/hls:ro
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
- playout
|
||||
- chat
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: linearvoid-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- linearvoid-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: linearvoid-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: linearvoid
|
||||
POSTGRES_USER: linearvoid
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-linearvoid}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- linearvoid-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U linearvoid"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: linearvoid-prometheus
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- prometheus-config:/etc/prometheus
|
||||
- prometheus-data:/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
networks:
|
||||
- linearvoid-net
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
||||
- "--web.console.templates=/etc/prometheus/consoles"
|
||||
- "--web.enable-lifecycle"
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: linearvoid-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
|
||||
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
- prometheus
|
||||
|
||||
networks:
|
||||
linearvoid-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
postgres-data:
|
||||
prometheus-data:
|
||||
prometheus-config:
|
||||
grafana-data:
|
||||
+32
-10
@@ -1,7 +1,6 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
playout:
|
||||
image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/playout:latest
|
||||
build:
|
||||
context: ./backend/playout
|
||||
dockerfile: Dockerfile
|
||||
@@ -25,6 +24,7 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
chat:
|
||||
image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/chat:latest
|
||||
build:
|
||||
context: ./backend/chat
|
||||
dockerfile: Dockerfile
|
||||
@@ -35,7 +35,9 @@ services:
|
||||
ports:
|
||||
- "${CHAT_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./data/chat:/app:rw # SQLite database and logs
|
||||
- ./data/chat:/app:rw
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:rw
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
@@ -49,16 +51,17 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
nginx:
|
||||
image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/nginx:latest
|
||||
build:
|
||||
context: ./infrastructure/nginx
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: infrastructure/nginx/Dockerfile
|
||||
container_name: linearvoid-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NGINX_PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./frontend/dist:/usr/share/nginx/html:ro
|
||||
- ./data/hls:/usr/share/nginx/html/hls:ro
|
||||
- ./frontend/dist:/srv/frontend:ro
|
||||
- ./data/hls:/srv/hls:ro
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
@@ -82,31 +85,48 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: linearvoid
|
||||
POSTGRES_USER: linearvoid
|
||||
POSTGRES_PASSWORD: linearvoid
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-linearvoid}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- linearvoid-net
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus
|
||||
image: prom/prometheus:latest
|
||||
container_name: linearvoid-prometheus
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
networks:
|
||||
- linearvoid-net
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
||||
- "--web.console.templates=/etc/prometheus/consoles"
|
||||
- "--web.enable-lifecycle"
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana
|
||||
image: grafana/grafana:latest
|
||||
container_name: linearvoid-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
|
||||
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
- prometheus
|
||||
|
||||
networks:
|
||||
linearvoid-net:
|
||||
@@ -115,3 +135,5 @@ networks:
|
||||
volumes:
|
||||
redis-data:
|
||||
postgres-data:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
+500
-187
@@ -4,18 +4,86 @@
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Introduction and Goals](#introduction-and-goals)
|
||||
- [Architecture Constraints](#architecture-constraints)
|
||||
- [Context and Scope](#context-and-scope)
|
||||
- [Solution Strategy](#solution-strategy)
|
||||
- [Building Block View](#building-block-view)
|
||||
- [Runtime View](#runtime-view)
|
||||
- [Deployment View](#deployment-view)
|
||||
- [Cross-cutting Concepts](#cross-cutting-concepts)
|
||||
- [Architecture Decisions](#architecture-decisions)
|
||||
- [Quality Requirements](#quality-requirements)
|
||||
- [Risks and Technical Debts](#risks-and-technical-debts)
|
||||
- [Glossary](#glossary)
|
||||
- [Architecture Documentation for Linear Void](#architecture-documentation-for-linear-void)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Companion Documentation](#companion-documentation)
|
||||
- [Introduction and Goals](#introduction-and-goals)
|
||||
- [Requirements Overview](#requirements-overview)
|
||||
- [Quality Goals](#quality-goals)
|
||||
- [Stakeholders](#stakeholders)
|
||||
- [Architecture Constraints](#architecture-constraints)
|
||||
- [Context and Scope](#context-and-scope)
|
||||
- [Business Context](#business-context)
|
||||
- [High-Level Context Diagram (Mermaid)](#high-level-context-diagram-mermaid)
|
||||
- [Technical Context](#technical-context)
|
||||
- [Solution Strategy](#solution-strategy)
|
||||
- [Building Block View](#building-block-view)
|
||||
- [Whitebox Overall System](#whitebox-overall-system)
|
||||
- [Level 2](#level-2)
|
||||
- [Level 3](#level-3)
|
||||
- [Runtime View](#runtime-view)
|
||||
- [Playout \& Delivery Scenario](#playout--delivery-scenario)
|
||||
- [Deployment View](#deployment-view)
|
||||
- [CI/CD Pipeline Architecture](#cicd-pipeline-architecture)
|
||||
- [Infrastructure Level 1](#infrastructure-level-1)
|
||||
- [Infrastructure Level 2](#infrastructure-level-2)
|
||||
- [Cross-cutting Concepts](#cross-cutting-concepts)
|
||||
- [Caching \& Cost Optimization](#caching--cost-optimization)
|
||||
- [Video Normalization \& Playout](#video-normalization--playout)
|
||||
- [Required Source Profile (Linear Void v1)](#required-source-profile-linear-void-v1)
|
||||
- [Reference Normalization Command](#reference-normalization-command)
|
||||
- [Verification Gate (ffprobe)](#verification-gate-ffprobe)
|
||||
- [Monitoring \& Resilience](#monitoring--resilience)
|
||||
- [Prometheus Baseline](#prometheus-baseline)
|
||||
- [Grafana Dashboards](#grafana-dashboards)
|
||||
- [Alerting Rules (Prometheus Rule Groups)](#alerting-rules-prometheus-rule-groups)
|
||||
- [API Documentation Reference](#api-documentation-reference)
|
||||
- [Chat WebSocket API](#chat-websocket-api)
|
||||
- [Playout and Delivery REST API](#playout-and-delivery-rest-api)
|
||||
- [Admin REST API](#admin-rest-api)
|
||||
- [Cloudflare Configuration Reference](#cloudflare-configuration-reference)
|
||||
- [DNS and Zone Routing](#dns-and-zone-routing)
|
||||
- [Cache Rules Baseline](#cache-rules-baseline)
|
||||
- [Edge Security and Controls](#edge-security-and-controls)
|
||||
- [Worker and Header Policy](#worker-and-header-policy)
|
||||
- [Operations and Validation](#operations-and-validation)
|
||||
- [Security \& Moderation](#security--moderation)
|
||||
- [Transport and Edge Protection](#transport-and-edge-protection)
|
||||
- [Reverse Proxy Enforcement (nginx)](#reverse-proxy-enforcement-nginx)
|
||||
- [Admin Authentication and Session Security](#admin-authentication-and-session-security)
|
||||
- [Rate Limiting and Abuse Controls](#rate-limiting-and-abuse-controls)
|
||||
- [CORS and Cross-Origin Behavior](#cors-and-cross-origin-behavior)
|
||||
- [Moderation and Privacy](#moderation-and-privacy)
|
||||
- [Chat](#chat)
|
||||
- [Architecture Decisions](#architecture-decisions)
|
||||
- [Quality Requirements](#quality-requirements)
|
||||
- [Performance Benchmarks and Capacity Planning](#performance-benchmarks-and-capacity-planning)
|
||||
- [Baseline SLO Targets (v1)](#baseline-slo-targets-v1)
|
||||
- [Workload Assumptions](#workload-assumptions)
|
||||
- [Initial Capacity Model](#initial-capacity-model)
|
||||
- [Benchmark Plan](#benchmark-plan)
|
||||
- [Capacity Triggers and Actions](#capacity-triggers-and-actions)
|
||||
- [Scaling Strategy](#scaling-strategy)
|
||||
- [Phase 1: Vertical + Cache-First (Current Baseline)](#phase-1-vertical--cache-first-current-baseline)
|
||||
- [Phase 2: Service Separation](#phase-2-service-separation)
|
||||
- [Phase 3: Multi-Origin Resilience](#phase-3-multi-origin-resilience)
|
||||
- [Phase 4: Regional and Throughput Expansion](#phase-4-regional-and-throughput-expansion)
|
||||
- [Scaling Triggers](#scaling-triggers)
|
||||
- [Disaster Recovery and Backup Procedures](#disaster-recovery-and-backup-procedures)
|
||||
- [Recovery Targets](#recovery-targets)
|
||||
- [Backup Scope](#backup-scope)
|
||||
- [Backup Cadence](#backup-cadence)
|
||||
- [Recovery Runbook (Minimum)](#recovery-runbook-minimum)
|
||||
- [Failure Modes and Strategy](#failure-modes-and-strategy)
|
||||
- [Risks and Technical Debts](#risks-and-technical-debts)
|
||||
- [Glossary](#glossary)
|
||||
|
||||
## Companion Documentation
|
||||
|
||||
- [API.md](../../API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline.
|
||||
|
||||
## Introduction and Goals
|
||||
|
||||
@@ -107,13 +175,6 @@ flowchart TD
|
||||
Chat --> VPS
|
||||
VPS -->|Proxy / Static| Pages
|
||||
CDN -->|Origin Pull on Miss| R2
|
||||
|
||||
classDef cloud fill:#f0f0ff,stroke:#4263eb
|
||||
classDef vps fill:#f0fff0,stroke:#2e7d32
|
||||
classDef user fill:#fff0f0,stroke:#c62828
|
||||
class Cloudflare,CDN,Pages,R2 cloud
|
||||
class VPS,Playout,Chat vps
|
||||
class Viewers,Admins user
|
||||
```
|
||||
|
||||
**External Interfaces**:
|
||||
@@ -196,7 +257,11 @@ graph TD
|
||||
|
||||
Detailed black-box descriptions, level-2/3 views, and code locations are in `adr/ADR-001.md` and future implementation ADRs.
|
||||
|
||||
### Level 2 & 3
|
||||
### Level 2
|
||||
|
||||
Deferred to implementation (see ADR for component responsibilities, interfaces, quality attributes, and risks). Playout enforces normalization; Delivery layer focuses on cache rules and immutable naming.
|
||||
|
||||
### Level 3
|
||||
|
||||
Deferred to implementation (see ADR for component responsibilities, interfaces, quality attributes, and risks). Playout enforces normalization; Delivery layer focuses on cache rules and immutable naming.
|
||||
|
||||
@@ -217,6 +282,58 @@ Notable aspects: Manifest freshness ensures all viewers stay on same timeline; c
|
||||
|
||||
## Deployment View
|
||||
|
||||
### 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.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Gitea Actions"
|
||||
CI[CI Workflow<br/>Build + Push Images]
|
||||
CD[CD Workflow<br/>Pull + Deploy]
|
||||
end
|
||||
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)"
|
||||
DOCKER[Docker Engine<br/>via TCP :2375]
|
||||
PLAYOUT[linearvoid-playout]
|
||||
CHAT[linearvoid-chat]
|
||||
NGINX[linearvoid-nginx]
|
||||
REDIS[linearvoid-redis]
|
||||
DB[linearvoid-db]
|
||||
PROM[linearvoid-prometheus]
|
||||
GRAF[linearvoid-grafana]
|
||||
end
|
||||
|
||||
CI -->|push| REGISTRY
|
||||
REGISTRY -->|pull| CD
|
||||
CD -->|deploy via DOCKER_HOST| DOCKER
|
||||
DOCKER --> PLAYOUT
|
||||
DOCKER --> CHAT
|
||||
DOCKER --> NGINX
|
||||
DOCKER --> REDIS
|
||||
DOCKER --> DB
|
||||
DOCKER --> PROM
|
||||
DOCKER --> GRAF
|
||||
```
|
||||
|
||||
**Pipeline Sequence:**
|
||||
|
||||
1. **CI Workflow** (`.gitea/workflows/ci.yml`):
|
||||
- Triggers on `push` to `main` or `pull_request`
|
||||
- Runs `test` job: frontend lint/build, Go validation, `docker compose build`
|
||||
- Runs `docker` job (after `test` passes, only on `main` push):
|
||||
- Builds `playout`, `chat`, `nginx` images with Buildx
|
||||
- Pushes to Gitea Container Registry with `main` and `latest` tags
|
||||
2. **CD Workflow** (`.gitea/workflows/cd.yml`):
|
||||
- Triggers on `workflow_run` when CI completes successfully, or manually via `workflow_dispatch`
|
||||
- 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`)
|
||||
- Runs health checks with retry logic for playout initialization
|
||||
|
||||
### Infrastructure Level 1
|
||||
|
||||
**Overview Diagram**: See Mermaid in Building Block section (VPS + Cloudflare R2/Pages/CDN).
|
||||
@@ -252,15 +369,224 @@ Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = ne
|
||||
|
||||
### Video Normalization & Playout
|
||||
|
||||
All source assets must be pre-normalized (H.264/AVC, AAC-LC, constant 30 fps, 2s keyframe interval, CRF). Prevents glitches during continuous concat. FFmpeg `-c copy` preferred for efficiency.
|
||||
All source assets must be pre-normalized before they are accepted into production playlists. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout.
|
||||
|
||||
#### Required Source Profile (Linear Void v1)
|
||||
|
||||
| Field | Required Value | Notes |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------- |
|
||||
| Container | MP4 | Single-video-asset input format |
|
||||
| Video codec | H.264/AVC (`libx264`) | Baseline delivery codec for broad compatibility |
|
||||
| Audio codec | AAC-LC | Stereo preferred |
|
||||
| Resolution | 1280x720 or 1920x1080 | Keep single resolution per playlist block when possible |
|
||||
| Framerate | Constant 30 fps (`-r 30`) | Variable frame rate content must be normalized |
|
||||
| GOP / keyframe interval | 2 seconds (`-g 60 -keyint_min 60`) | Matches HLS segment boundaries better |
|
||||
| Pixel format | `yuv420p` | Browser compatibility baseline |
|
||||
| Audio sample rate | 48 kHz | Stable timing for concat playout |
|
||||
| Audio bitrate | 128k-192k | Predictable quality envelope |
|
||||
|
||||
#### Reference Normalization Command
|
||||
|
||||
```bash
|
||||
ffmpeg -i input.mov \
|
||||
-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 160k -ar 48000 -ac 2 \
|
||||
-movflags +faststart \
|
||||
output-normalized.mp4
|
||||
```
|
||||
|
||||
#### Verification Gate (ffprobe)
|
||||
|
||||
- Validate codec, fps, resolution, and keyframe cadence before upload acceptance.
|
||||
- Reject non-conforming files from admin ingest with actionable error feedback.
|
||||
- Keep verification logic aligned with runtime playout assumptions to preserve `-c copy` efficiency where possible.
|
||||
|
||||
### Monitoring & Resilience
|
||||
|
||||
Alert on no new segment (>30–60s), low cache hits, high R2 ops, WebSocket errors. Maintain emergency slate, auto-restart, weekly backups, incident runbook.
|
||||
Linear Void uses a two-layer monitoring model:
|
||||
|
||||
1. **Application layer (chat + platform health)** via Prometheus metrics and a lightweight Grafana dashboard.
|
||||
2. **Infrastructure/runtime layer (Prometheus self-observability, TSDB, query engine, Go runtime)** via a detailed Grafana dashboard.
|
||||
|
||||
#### Prometheus Baseline
|
||||
|
||||
- Config file: `monitoring/prometheus/prometheus.yml`
|
||||
- Default scrape interval: `15s`
|
||||
- Current scrape jobs: `chat` -> `chat:3000`, `prometheus` -> `prometheus:9090`.
|
||||
|
||||
Recommended next-step scrape targets (if/when exporters are introduced):
|
||||
|
||||
- `nginx` (via nginx-exporter)
|
||||
- `redis` (via redis-exporter)
|
||||
- `postgres` (via postgres-exporter)
|
||||
- `node` (via node-exporter)
|
||||
|
||||
#### Grafana Dashboards
|
||||
|
||||
- `monitoring/grafana/dashboard.json` (Linear Void app metrics: connected clients, messages, moderation actions, Redis reachability, admin sessions, scrape health).
|
||||
- `monitoring/grafana/dashboards/dashboard.json` (Prometheus operational visibility: status, uptime, CPU/memory, TSDB ingestion/churn/errors, HTTP latencies, query load, GC/scheduler).
|
||||
|
||||
Use the app dashboard for service-level behavior and the Prometheus overview dashboard for platform diagnostics and capacity planning.
|
||||
|
||||
#### Alerting Rules (Prometheus Rule Groups)
|
||||
|
||||
Define alert rules as grouped policies (for example in `monitoring/prometheus/alerts.yml`) and include them from `prometheus.yml`.
|
||||
|
||||
Minimum recommended alert set:
|
||||
|
||||
- **Availability**
|
||||
`up{job="chat"} == 0` for `2m`; `up{job="prometheus"} == 0` for `2m`.
|
||||
- **Chat service health**
|
||||
`linearvoid_chat_redis_up == 0` for `2m`; sudden disconnect spike / connected clients collapse.
|
||||
- **Playout continuity**
|
||||
No HLS segment progression for `30-60s` (from playout-specific metric when available).
|
||||
- **Prometheus capacity and ingestion health**
|
||||
Sustained growth in `prometheus_tsdb_wal_writes_failed_total` or `prometheus_tsdb_compactions_failed_total`, abnormal jump in `prometheus_tsdb_out_of_order_samples_total`, and high `prometheus_engine_queries` near concurrent limit.
|
||||
- **Latency and user-impact**
|
||||
Elevated p99 handler latency from `prometheus_http_request_duration_seconds_bucket` and elevated p99 query execution latency from `prometheus_engine_query_duration_histogram_seconds_bucket`.
|
||||
|
||||
Route warning/critical severities to the team channel and keep runbook links attached to each alert definition.
|
||||
|
||||
Operational resilience baseline:
|
||||
|
||||
- Emergency slate fallback for playout failure.
|
||||
- Auto-restart policies for playout/chat/nginx containers.
|
||||
- Weekly backup and restore drills for stateful data (`data/chat`, playlist metadata, Postgres).
|
||||
- Incident runbook with triage order: edge reachability -> origin health -> playout progression -> chat connectivity -> data-plane dependencies.
|
||||
|
||||
### API Documentation Reference
|
||||
|
||||
Linear Void currently exposes three operational API surfaces. The canonical detailed contract should live in `API.md`, while this section provides the architecture-level map.
|
||||
|
||||
#### Chat WebSocket API
|
||||
|
||||
- Endpoint: `/ws`
|
||||
- Transport: `wss://` in production, `ws://` locally
|
||||
- Query parameters:
|
||||
- `username`
|
||||
- `room`
|
||||
- Message envelope (typical):
|
||||
- `room`
|
||||
- `username`
|
||||
- `text`
|
||||
- `type` (`public`, `private`, `system`)
|
||||
- Responsibilities:
|
||||
- room fan-out
|
||||
- private DM channel routing
|
||||
- moderation command handling
|
||||
- rate limiting and presence integration
|
||||
|
||||
#### Playout and Delivery REST API
|
||||
|
||||
- Health endpoints:
|
||||
- playout health (`/health` on playout service)
|
||||
- edge/origin health (`/health` via nginx)
|
||||
- Stream delivery:
|
||||
- HLS manifest and segment path namespace under `/hls/`
|
||||
- Responsibilities:
|
||||
- expose liveness for orchestration and monitoring
|
||||
- provide stable HLS path contract for viewer and CDN
|
||||
|
||||
#### Admin REST API
|
||||
|
||||
- Auth/session:
|
||||
- `/api/admin/login`
|
||||
- `/api/admin/logout`
|
||||
- `/api/admin/session`
|
||||
- Operational/admin domains:
|
||||
- overview, analytics, users, moderation, schedule, playlist, media
|
||||
- Media upload domain:
|
||||
- `/api/admin/media/uploads` (init/resume)
|
||||
- `/api/admin/media/uploads/{uploadId}/chunk`
|
||||
- `/api/admin/media/uploads/{uploadId}/complete`
|
||||
- Responsibilities:
|
||||
- protected control plane for operator workflows
|
||||
- media metadata + upload orchestration
|
||||
- moderation/audit and system status management
|
||||
|
||||
### Cloudflare Configuration Reference
|
||||
|
||||
Cloudflare is part of the production control plane, not only a CDN edge. The following configuration set is required to maintain cost, latency, and reliability targets.
|
||||
|
||||
#### DNS and Zone Routing
|
||||
|
||||
- `linearvoid.tv` -> application origin (nginx)
|
||||
- `www.linearvoid.tv` -> redirect to apex
|
||||
- `media.linearvoid.tv` -> media domain for HLS/object delivery when split-domain mode is enabled
|
||||
|
||||
#### Cache Rules Baseline
|
||||
|
||||
- HLS segments (`.ts`, `.m4s`):
|
||||
- Cache Everything
|
||||
- long TTL (immutable)
|
||||
- respect immutable semantics
|
||||
- Manifests (`.m3u8`):
|
||||
- short TTL (1-10s)
|
||||
- revalidation-friendly policy for timeline freshness
|
||||
|
||||
#### Edge Security and Controls
|
||||
|
||||
- Enforce HTTPS/WSS and redirect HTTP to HTTPS.
|
||||
- Enable WAF baseline protections for common web exploits.
|
||||
- Apply rate limits for abusive request patterns (especially auth/admin paths).
|
||||
- Keep origin exposure minimized (prefer Cloudflare-only public ingress where feasible).
|
||||
|
||||
#### Worker and Header Policy
|
||||
|
||||
- Use Worker logic for immutable cache headers and request normalization where cache rules alone are insufficient.
|
||||
- Keep CORS behavior explicit for HLS resources to support browser playback.
|
||||
|
||||
#### Operations and Validation
|
||||
|
||||
- Validate cache behavior after deployment:
|
||||
- expected cache status progression (MISS -> HIT)
|
||||
- manifest freshness under active playout
|
||||
- Track edge metrics and adjust:
|
||||
- cache hit ratio
|
||||
- request volume and error rates
|
||||
- ToS/cost posture for sustained traffic profiles
|
||||
|
||||
### Security & Moderation
|
||||
|
||||
HTTPS/WSS only, strict CORS, Cloudflare WAF, authenticated admin paths, rate limiting, immutable moderation log. Private DMs not E2EE by default.
|
||||
Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
|
||||
|
||||
#### Transport and Edge Protection
|
||||
|
||||
- Public traffic uses HTTPS/WSS at the edge.
|
||||
- Cloudflare WAF and rate limiting are part of the baseline (see `infrastructure/cloudflare/cache-rules.md`).
|
||||
- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
|
||||
|
||||
#### Reverse Proxy Enforcement (nginx)
|
||||
|
||||
- Admin UI and admin API paths are protected by `auth_request` before reaching backend handlers (`/admin/`, `/api/admin/`).
|
||||
- Unauthorized admin UI requests are redirected to `/admin/login/`.
|
||||
- Unauthorized admin API requests return JSON `401` from a dedicated location handler.
|
||||
- WebSocket upgrade headers are explicitly forwarded for `/ws`.
|
||||
|
||||
#### Admin Authentication and Session Security
|
||||
|
||||
- Admin authentication is cookie-session based (`linearvoid_admin_session`).
|
||||
- Session cookie controls in backend include `HttpOnly`, `SameSite=Strict`, and `Secure` when request is HTTPS (derived from TLS or `X-Forwarded-Proto`).
|
||||
- Session expiration is enforced server-side.
|
||||
|
||||
#### Rate Limiting and Abuse Controls
|
||||
|
||||
- Chat message rate limiting uses Redis `INCR + EXPIRE` windowing.
|
||||
- Current baseline in chat handler: `>10` messages within `10s` triggers a user-facing throttle response.
|
||||
- If Redis is unavailable, presence/rate-limit features degrade gracefully (warning logged; core chat remains available).
|
||||
|
||||
#### CORS and Cross-Origin Behavior
|
||||
|
||||
- HLS endpoints exposed by nginx include explicit `Access-Control-Allow-Origin: *` for player compatibility.
|
||||
- Admin/authenticated routes are expected to run same-origin behind nginx auth gates.
|
||||
|
||||
#### Moderation and Privacy
|
||||
|
||||
- Moderation commands (`/mod`, `/mute`, `/ban`) are handled in backend chat flow and persisted through chat storage paths.
|
||||
- Private DMs are application-level private channels, not end-to-end encrypted in current architecture.
|
||||
- Moderation and audit retention policies should be formalized in runbook/ops docs as part of production hardening.
|
||||
|
||||
### Chat
|
||||
|
||||
@@ -274,22 +600,168 @@ The primary decisions are captured in the following consolidated ADRs (all locat
|
||||
|
||||
- **[ADR-001](./adr/ADR-001.md)**: Overall architecture, HLS + Cloudflare R2 caching strategy, storage calculations, cost model, risks.
|
||||
- **[ADR-002](./adr/ADR-002-module-structure.md)**: Chosen project folder/module structure (frontend, backend/playout, backend/chat, infrastructure, scripts, monitoring) and rationale.
|
||||
- **[ADR-003](./adr/ADR-003-tech-stack.md)**: Technology stack, programming language choices per module (TypeScript frontend, Go for chat/playout wrapper, Bash/Python scripts), Docker-first approach, and explicit documentation of all implicit assumptions (normalization requirement, Cloudflare-centric delivery, monitoring focus, etc.).
|
||||
- **[ADR-003](./adr/ADR-003-tech-stack.md)**: Technology stack, programming language choices per module (TypeScript frontend, Go for chat/playout wrapper, Bash/Python scripts), Docker-first approach, and explicit documentation of all implicit assumptions.
|
||||
- **[ADR-004](./adr/ADR-004-admin-video.md)**: Video admin web interface (upload, playlist management, schedule overrides, normalization enforcement).
|
||||
- **[ADR-005](./adr/ADR-005-admin-moderation-analytics.md)**: Web admin interfaces for user management, moderation dashboard, analytics, and system configuration.
|
||||
- **[ADR-006](./adr/ADR-006-data-storage.md)**: Data storage strategy (SQLite vs PostgreSQL for v1, migration path, admin interface capabilities).
|
||||
- **[ADR-007](./adr/ADR-007-redis-usage.md)**: Redis usage (exact use cases, TTL policies, persistence, fallback behavior, admin configuration).
|
||||
|
||||
These ADRs together form the canonical decision log. Future ADRs (e.g., Docker Compose layout, API contracts, monitoring strategy) will be added to the same folder and referenced here.
|
||||
These ADRs together form the canonical decision log.
|
||||
|
||||
**Summary of Key Decisions** (from ADR-001/002/003):
|
||||
**Summary of Key Decisions** (from ADR-001 through ADR-007):
|
||||
|
||||
- Self-managed US-East playout (Hetzner Ashburn) with FFmpeg + MediaMTX/SRS → HLS → Cloudflare R2 + edge caching.
|
||||
- Module structure that directly reflects the arc42 building blocks and context diagram.
|
||||
- Go preferred for performance-critical services; TypeScript for frontend; Docker for all deployment.
|
||||
|
||||
See the individual ADR files for full context, alternatives considered, consequences, and references.
|
||||
- Admin interfaces for video management, moderation, analytics, and system configuration using HTMX + Tailwind.
|
||||
- SQLite as primary database for v1 with PostgreSQL migration path if needed.
|
||||
- Redis for rate limiting, presence, pub/sub, session storage with defined TTL policies and fallback behavior.
|
||||
|
||||
## Quality Requirements
|
||||
|
||||
See **Quality Goals** table in the Introduction section. Primary scenarios focus on cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), and usability (synchronized player + responsive chat).
|
||||
|
||||
### Performance Benchmarks and Capacity Planning
|
||||
|
||||
#### Baseline SLO Targets (v1)
|
||||
|
||||
- Playback startup: target < 4s after first manifest request on warm cache.
|
||||
- Steady-state playback: no segment starvation during normal operation.
|
||||
- End-to-end HLS latency: expected 15-35s (standard HLS trade-off).
|
||||
- Chat delivery latency: p95 < 500ms inside same region under normal load.
|
||||
- Admin page responsiveness: p95 API response < 1s for common read operations.
|
||||
|
||||
#### Workload Assumptions
|
||||
|
||||
- Primary audience in US; origin in US-East.
|
||||
- Edge cache serves most segment traffic; origin handles manifest churn and cache misses.
|
||||
- Chat traffic scales with concurrent viewers but remains low-bandwidth compared to video.
|
||||
|
||||
#### Initial Capacity Model
|
||||
|
||||
- Video bitrate envelopes (from ADR-001):
|
||||
- 720p30: ~2.6-4.2 Mbps total with audio.
|
||||
- 1080p30: ~5.1-8.2 Mbps total with audio.
|
||||
- Storage planning for 48h rolling library:
|
||||
- 720p30: ~57-90 GB raw (allocate ~80-110 GB with headroom).
|
||||
- 1080p30: ~110-175 GB raw (allocate ~130-180 GB with headroom).
|
||||
- Cost behavior:
|
||||
- Viewer scale is mostly constrained by cache efficiency and request volume, not by direct origin egress.
|
||||
|
||||
#### Benchmark Plan
|
||||
|
||||
- Playback path:
|
||||
- Measure time-to-first-frame and rebuffer events from multiple US regions.
|
||||
- Track manifest freshness and segment availability at edge.
|
||||
- Chat path:
|
||||
- Load-test concurrent websocket connections and message fan-out.
|
||||
- Measure p50/p95 send-to-receive latency and Redis fallback behavior.
|
||||
- Admin/API path:
|
||||
- Benchmark playlist/system endpoints for p95 latency and error rate.
|
||||
- Platform path:
|
||||
- Observe TSDB ingestion, query p99, and GC pause behavior in Grafana.
|
||||
|
||||
#### Capacity Triggers and Actions
|
||||
|
||||
- Trigger: sustained viewer growth with rising cache-miss ratio.
|
||||
- Action: tighten cache policy, enable/verify Tiered Cache, review manifest TTL.
|
||||
- Trigger: chat p95 latency drift or websocket error growth.
|
||||
- Action: scale chat service, tune Redis, separate noisy moderation workloads.
|
||||
- Trigger: Prometheus WAL/compaction failures or query saturation.
|
||||
- Action: tune retention/cardinality, increase resources, split dashboards/queries.
|
||||
- Trigger: origin CPU/memory pressure on playout host.
|
||||
- Action: optimize FFmpeg profile, isolate services, add secondary origin.
|
||||
|
||||
### Scaling Strategy
|
||||
|
||||
Scaling is designed to preserve cost efficiency first, then increase throughput and resilience in controlled phases.
|
||||
|
||||
#### Phase 1: Vertical + Cache-First (Current Baseline)
|
||||
|
||||
- Single primary origin in US-East with strong Cloudflare edge caching.
|
||||
- Optimize segment immutability and manifest TTL before adding infrastructure.
|
||||
- Scale compute vertically when CPU/memory pressure appears on playout/chat host.
|
||||
|
||||
#### Phase 2: Service Separation
|
||||
|
||||
- Split playout, chat, and observability workloads across dedicated nodes/VMs.
|
||||
- Isolate noisy workloads (analytics, heavy admin tasks) from latency-sensitive chat paths.
|
||||
- Introduce managed PostgreSQL/Redis or dedicated state nodes where operationally justified.
|
||||
|
||||
#### Phase 3: Multi-Origin Resilience
|
||||
|
||||
- Add warm standby or active-secondary origin.
|
||||
- Use health-aware DNS/origin failover strategy.
|
||||
- Keep HLS path and cache contract stable so viewer impact during failover is minimized.
|
||||
|
||||
#### Phase 4: Regional and Throughput Expansion
|
||||
|
||||
- Add additional origin region only when audience distribution and latency justify it.
|
||||
- Keep chat affinity strategy explicit (regional routing, state replication considerations).
|
||||
- Continuously evaluate cache miss economics vs additional origin complexity.
|
||||
|
||||
#### Scaling Triggers
|
||||
|
||||
- Rising cache miss ratio with sustained traffic growth.
|
||||
- Chat p95/p99 latency drift or websocket error growth.
|
||||
- Persistent origin resource saturation despite optimization.
|
||||
- Increased blast-radius concerns from single-host dependency.
|
||||
|
||||
### Disaster Recovery and Backup Procedures
|
||||
|
||||
#### Recovery Targets
|
||||
|
||||
- RPO target: <= 24h for chat/moderation/admin metadata.
|
||||
- RTO target: <= 60 minutes for restoring baseline streaming + chat operations.
|
||||
|
||||
#### Backup Scope
|
||||
|
||||
- Persistent service volumes:
|
||||
- Postgres data (`postgres-data` volume).
|
||||
- Redis append-only persistence (`redis-data` volume) where enabled.
|
||||
- Prometheus long-term TSDB (`prometheus-data` volume) if historical metrics retention is required.
|
||||
- Grafana state (`grafana-data` volume) if dashboards/users are managed in-instance.
|
||||
- Host-mounted state directories:
|
||||
- `data/chat`
|
||||
- `data/playlist`
|
||||
- `data/media` (authoritative source may be R2/object storage)
|
||||
- `data/hls` (optional; can be regenerated by playout)
|
||||
- Configuration and infra source of truth:
|
||||
- Compose files, nginx config, monitoring configs, and environment template.
|
||||
|
||||
#### Backup Cadence
|
||||
|
||||
- Daily logical Postgres backups (plus weekly full snapshot).
|
||||
- Daily sync of chat/playlist state directories.
|
||||
- Weekly integrity check by restoring backups into an isolated environment.
|
||||
- Retention policy baseline:
|
||||
- Daily backups: 14 days
|
||||
- Weekly backups: 8 weeks
|
||||
- Monthly backups: 6 months
|
||||
|
||||
#### Recovery Runbook (Minimum)
|
||||
|
||||
1. Restore infrastructure configuration (compose, nginx, env, monitoring config).
|
||||
2. Restore Postgres and Redis persisted state.
|
||||
3. Restore `data/playlist` and chat persistence directories.
|
||||
4. Rehydrate media from authoritative object storage if local media is missing.
|
||||
5. Start stack and validate in order:
|
||||
- `playout` health endpoint
|
||||
- `chat` health endpoint
|
||||
- nginx `/health`
|
||||
- HLS playlist availability
|
||||
- websocket chat connectivity
|
||||
6. Verify admin authentication and moderation audit visibility.
|
||||
7. Announce recovery and continue elevated monitoring for at least one playlist cycle.
|
||||
|
||||
#### Failure Modes and Strategy
|
||||
|
||||
- Single-host loss: redeploy stack on standby host, restore data backups, repoint DNS/edge origin.
|
||||
- Storage corruption: restore last known-good snapshot and replay latest logical backups.
|
||||
- Partial service failure: replace only impacted service and restore scoped data (for example Postgres only).
|
||||
|
||||
Document and rehearse this runbook quarterly; recovery that is not exercised is not reliable.
|
||||
|
||||
## Risks and Technical Debts
|
||||
|
||||
- Cloudflare ToS risk for heavy video on free tier (use R2 path; fallback to BunnyCDN if challenged).
|
||||
@@ -317,162 +789,3 @@ See **Quality Goals** table in the Introduction section. Primary scenarios focus
|
||||
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
||||
|
||||
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
|
||||
|
||||
### Whitebox Overall System
|
||||
|
||||
**_\<Overview Diagram\>_**
|
||||
|
||||
Motivation
|
||||
|
||||
: _\<text explanation\>_
|
||||
|
||||
Contained Building Blocks
|
||||
|
||||
: _\<Description of contained building block (black boxes)\>_
|
||||
|
||||
Important Interfaces
|
||||
|
||||
: _\<Description of important interfaces\>_
|
||||
|
||||
#### \<Name black box 1\>
|
||||
|
||||
_\<Purpose/Responsibility\>_
|
||||
|
||||
_\<Interface(s)\>_
|
||||
|
||||
_\<(Optional) Quality/Performance Characteristics\>_
|
||||
|
||||
_\<(Optional) Directory/File Location\>_
|
||||
|
||||
_\<(Optional) Fulfilled Requirements\>_
|
||||
|
||||
_\<(optional) Open Issues/Problems/Risks\>_
|
||||
|
||||
#### \<Name black box 2\>
|
||||
|
||||
_\<black box template\>_
|
||||
|
||||
#### \<Name black box n\>
|
||||
|
||||
_\<black box template\>_
|
||||
|
||||
#### \<Name interface 1\>
|
||||
|
||||
...
|
||||
|
||||
#### \<Name interface m\>
|
||||
|
||||
### Level 2
|
||||
|
||||
#### White Box _\<building block 1\>_
|
||||
|
||||
_\<white box template\>_
|
||||
|
||||
#### White Box _\<building block 2\>_
|
||||
|
||||
_\<white box template\>_
|
||||
|
||||
...
|
||||
|
||||
#### White Box _\<building block m\>_
|
||||
|
||||
_\<white box template\>_
|
||||
|
||||
### Level 3
|
||||
|
||||
#### White Box \<\_building block x.1\_\>
|
||||
|
||||
_\<white box template\>_
|
||||
|
||||
#### White Box \<\_building block x.2\_\>
|
||||
|
||||
_\<white box template\>_
|
||||
|
||||
#### White Box \<\_building block y.1\_\>
|
||||
|
||||
_\<white box template\>_
|
||||
|
||||
## Runtime View
|
||||
|
||||
### \<Runtime Scenario 1\>
|
||||
|
||||
- _\<insert runtime diagram or textual description of the scenario\>_
|
||||
|
||||
- _\<insert description of the notable aspects of the interactions
|
||||
between the building block instances depicted in this diagram.\>_
|
||||
|
||||
### \<Runtime Scenario 2\>
|
||||
|
||||
### ...
|
||||
|
||||
### \<Runtime Scenario n\>
|
||||
|
||||
## Deployment View
|
||||
|
||||
### Infrastructure Level 1
|
||||
|
||||
**_\<Overview Diagram\>_**
|
||||
|
||||
Motivation
|
||||
|
||||
: _\<explanation in text form\>_
|
||||
|
||||
Quality and/or Performance Features
|
||||
|
||||
: _\<explanation in text form\>_
|
||||
|
||||
Mapping of Building Blocks to Infrastructure
|
||||
|
||||
: _\<description of the mapping\>_
|
||||
|
||||
### Infrastructure Level 2
|
||||
|
||||
#### _\<Infrastructure Element 1\>_
|
||||
|
||||
_\<diagram + explanation\>_
|
||||
|
||||
#### _\<Infrastructure Element 2\>_
|
||||
|
||||
_\<diagram + explanation\>_
|
||||
|
||||
...
|
||||
|
||||
#### _\<Infrastructure Element n\>_
|
||||
|
||||
_\<diagram + explanation\>_
|
||||
|
||||
## Cross-cutting Concepts
|
||||
|
||||
### _\<Concept 1\>_
|
||||
|
||||
_\<explanation\>_
|
||||
|
||||
### _\<Concept 2\>_
|
||||
|
||||
_\<explanation\>_
|
||||
|
||||
...
|
||||
|
||||
### _\<Concept n\>_
|
||||
|
||||
_\<explanation\>_
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
## Quality Requirements
|
||||
|
||||
### Quality Requirements Overview
|
||||
|
||||
### Quality Scenarios
|
||||
|
||||
## Risks and Technical Debts
|
||||
|
||||
## Glossary
|
||||
|
||||
+----------------------+-----------------------------------------------+
|
||||
| Term | Definition |
|
||||
+======================+===============================================+
|
||||
| _\<Term-1\>_ | _\<definition-1\>_ |
|
||||
+----------------------+-----------------------------------------------+
|
||||
| _\<Term-2\>_ | _\<definition-2\>_ |
|
||||
+----------------------+-----------------------------------------------+
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-006: Data Storage Strategy (SQLite vs PostgreSQL)
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-08-02
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-08-04
|
||||
**Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005)
|
||||
**Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md)
|
||||
|
||||
@@ -12,89 +12,78 @@ The system needs persistent storage for:
|
||||
- Chat messages and history (public rooms and private DMs).
|
||||
- User accounts, roles, and ban lists.
|
||||
- Moderation audit log (immutable record of admin and moderator actions).
|
||||
- Playlist metadata and schedule overrides.
|
||||
- System configuration (including Redis settings adjustable via admin UI per ADR-005/ADR-007).
|
||||
- Playlist and media metadata used by admin flows.
|
||||
- System configuration visibility in admin APIs.
|
||||
|
||||
From ADR-003 we prefer operational simplicity. ADR-005 requires a robust audit trail. We must decide between **SQLite** (embedded, zero-config) and **PostgreSQL** (full-featured, scalable) and document how the admin interface can view and adjust storage-related configuration.
|
||||
The codebase now ships with a PostgreSQL service in Docker Compose (`db`) and chat is configured with a PostgreSQL `DATABASE_URL` by default. The backend still supports SQLite for optional local fallback, but the deployed and compose-default path is PostgreSQL.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
1. **Operational Simplicity** – Minimize infrastructure (no separate DB server for SQLite).
|
||||
2. **Performance & Concurrency** – Chat and moderation may have high write volume.
|
||||
3. **Backup and Recovery** – Easy point-in-time recovery and replication.
|
||||
4. **Admin Capabilities** – Admin UI must be able to view storage stats, run vacuum/analyze, and switch between SQLite and PostgreSQL (with migration path).
|
||||
5. **Auditability** – Moderation log must be immutable and queryable.
|
||||
6. **Cost** – Avoid managed database costs during early stages.
|
||||
1. **Implemented Reality** - Docker Compose already provisions PostgreSQL and chat depends on it.
|
||||
2. **Concurrency & Throughput** - Chat, moderation, and media metadata writes require stronger concurrent write behavior.
|
||||
3. **Operational Traceability** - Structured backups, service health checks, and persistent volume management are already in place for PostgreSQL.
|
||||
4. **Admin Growth** - Upcoming admin features (media CRUD, analytics, moderation tooling) benefit from relational querying and indexing.
|
||||
5. **Fallback Simplicity** - Keep SQLite support for lightweight local or recovery scenarios without blocking developer workflows.
|
||||
|
||||
## Decision
|
||||
|
||||
**Primary storage for v1: SQLite** (embedded in the chat and admin services).
|
||||
**Primary storage for current implementation: PostgreSQL (Docker Compose `db` service).**
|
||||
|
||||
**Migration Path**: Provide a clear upgrade path to PostgreSQL when concurrency or replication needs exceed SQLite capabilities (target v2 or when concurrent users > 500).
|
||||
**Secondary/Fallback mode:** SQLite remains supported via `DATABASE_URL=sqlite://...` for local-only scenarios.
|
||||
|
||||
**Admin Interface Capabilities** (extension of ADR-005):
|
||||
**Current Backend Behavior**:
|
||||
|
||||
- "System Configuration" tab showing current storage engine, database size, connection stats, and vacuum status.
|
||||
- Buttons to run `VACUUM`, `ANALYZE`, or trigger a backup.
|
||||
- Configuration form to switch connection string (SQLite file path vs PostgreSQL URL) and restart services gracefully.
|
||||
- Audit log of all configuration changes.
|
||||
- Chat backend (`backend/chat/src/main.go`) resolves `DATABASE_URL` at startup.
|
||||
- `postgres://` or `postgresql://` selects PostgreSQL driver.
|
||||
- `sqlite://` selects SQLite driver.
|
||||
- Empty `DATABASE_URL` falls back to local `chat.db` for developer convenience.
|
||||
- GORM AutoMigrate is used for chat/admin data models.
|
||||
|
||||
**Technical Details**:
|
||||
**Media Model Decision (Admin UI Feature Track)**:
|
||||
|
||||
- Use SQLite with WAL mode for better concurrency.
|
||||
- Use GORM or sqlx (Go) or Prisma (if Node.js fallback) for ORM/migrations.
|
||||
- Store large chat history in a separate table with retention policy (e.g., 30 days).
|
||||
- Moderation log as an append-only table with triggers for immutability.
|
||||
|
||||
**Folder Placement** (per ADR-002):
|
||||
|
||||
- Database models and migrations in `backend/shared/db/` or `backend/chat/internal/db/`.
|
||||
- Admin UI pages for storage management in `frontend/admin/system/`.
|
||||
- Media metadata is persisted in the same database as messages and moderation logs.
|
||||
- Physical media files remain on the shared filesystem (`/app/media`, mapped from `data/media`).
|
||||
- The database is the source of truth for media library metadata and future runtime analytics.
|
||||
|
||||
## Rationale
|
||||
|
||||
- SQLite aligns with ADR-003's emphasis on operational simplicity and low resource usage for the initial deployment on a single Hetzner VPS.
|
||||
- It eliminates the need for a separate database container in `docker-compose.yml` during early development.
|
||||
- The admin interface can provide visibility and basic maintenance operations, fulfilling the requirement from the previous TODO plan.
|
||||
- PostgreSQL can be introduced later with a migration script when scale demands it (replication, advanced querying, full-text search on chat).
|
||||
- The repository already operationalizes PostgreSQL in both `docker-compose.yml` and `docker-compose.prod.yml` with persistent storage.
|
||||
- Default environment documentation (`.env.example`, setup docs) already points to PostgreSQL.
|
||||
- This aligns architecture documentation with implemented behavior and avoids drift.
|
||||
- Keeping SQLite support preserves local flexibility while avoiding a hard breaking change.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**:
|
||||
|
||||
- Zero-config database for quick local development and small-scale production.
|
||||
- Simple backups (just copy the `.db` file).
|
||||
- Admin UI can directly manage and monitor storage without external tools.
|
||||
- Better write concurrency and query flexibility for chat + admin workloads.
|
||||
- Clear operational model for containerized deployments (health checks, named volume, service dependency).
|
||||
- Easier extension for admin analytics and media metadata features.
|
||||
|
||||
**Negative / Risks**:
|
||||
|
||||
- SQLite has limited concurrency compared to PostgreSQL (WAL mode helps but is not a full replacement).
|
||||
- Large chat history or analytics queries may become slow – plan retention policy and archiving.
|
||||
- Migration to PostgreSQL will require downtime or dual-write strategy (documented in this ADR).
|
||||
- Additional service to operate versus embedded-only SQLite.
|
||||
- Misconfigured `DATABASE_URL` can still route to SQLite fallback unintentionally if left empty outside compose.
|
||||
- Requires backup/restore procedures for PostgreSQL volume in production runbooks.
|
||||
|
||||
**Alternatives Considered**:
|
||||
## Alternatives Considered
|
||||
|
||||
- PostgreSQL from day one – rejected for added operational complexity (separate service, connection pooling, managed service cost).
|
||||
- Pure key-value store (e.g., BoltDB) – rejected because SQL is better for audit logs and complex moderation queries.
|
||||
- Managed database (Neon, Supabase) – rejected to maintain full self-hosted independence per ADR-001.
|
||||
- SQLite-first in production: rejected for current implementation because compose and deployment already run PostgreSQL.
|
||||
- PostgreSQL-only with no fallback: rejected to preserve local dev and troubleshooting flexibility.
|
||||
- Managed external PostgreSQL: deferred; self-hosted compose remains the preferred operational model.
|
||||
|
||||
## Implementation Plan
|
||||
## Implementation Status
|
||||
|
||||
1. Create this ADR-006 (done).
|
||||
2. Add SQLite support in `backend/shared/db/` with GORM migrations.
|
||||
3. Implement admin UI pages for storage monitoring and configuration (linked to ADR-005).
|
||||
4. Add configuration reload capability without full restart where possible.
|
||||
5. Write migration script from SQLite to PostgreSQL.
|
||||
6. Update `docker-compose.yml` to optionally include PostgreSQL.
|
||||
7. Add tests for data access patterns and admin configuration changes.
|
||||
|
||||
**Next ADR**: ADR-007 (Redis usage and admin configuration capabilities).
|
||||
1. ADR updated to match implemented state. Done.
|
||||
2. Compose includes PostgreSQL service and persistent volume. Done.
|
||||
3. Chat backend supports PostgreSQL and SQLite based on `DATABASE_URL`. Done.
|
||||
4. Media metadata persistence model documented here for Admin UI track. In progress (implementation ongoing).
|
||||
5. Admin storage observability and operations UI (vacuum/analyze/backup flows). Planned.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-001: Chat persistence, moderation log, monitoring requirements.
|
||||
- ADR-003: Preference for operational simplicity and Go backend.
|
||||
- ADR-005: Admin interface for user management, moderation, and analytics – this ADR extends it with storage and Redis config capabilities.
|
||||
- arc42 documentation: Cross-cutting Concepts (data management, monitoring) and Risks & Technical Debts.
|
||||
|
||||
This ADR ensures all data storage decisions are documented before code is written, maintaining full traceability.
|
||||
- ADR-005: Admin interface for user management, moderation, and analytics.
|
||||
- ADR-007: Redis usage for presence, rate limiting, pub/sub.
|
||||
- `docker-compose.yml` and `docker-compose.prod.yml` database service definitions.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
@@ -0,0 +1,34 @@
|
||||
# Brand Research: "Linear Void"
|
||||
|
||||
## 1. Trademark & Brand Availability (US Market)
|
||||
|
||||
Based on searches of the USPTO database and general web usage, **"Linear Void" is highly viable for a video streaming platform.**
|
||||
|
||||
Trademark rights are granted based on specific classes of goods and services. Currently, there are no registered trademarks or active companies using "Linear Void" in the media, broadcasting, or entertainment spaces.
|
||||
|
||||
**Existing uses of the phrase (Non-conflicting):**
|
||||
|
||||
- **Software / Engineering:** There is a well-known CAD/BIM software plugin called **"LINEAR Void Manager"** developed by a German company (LINEAR GmbH) for architects to plan wall openings (voids) in HVAC design. This falls under structural engineering software and does not conflict with media broadcasting.
|
||||
- **Astrophysics / Science:** "Linear void" is a common scientific term used in astrophysics to describe the evolution of underdense regions in the universe (cosmic voids).
|
||||
- **Music:** There is an electronic music album titled _Linear Void_. Album titles alone do not typically constitute a conflicting brand trademark.
|
||||
|
||||
**Trademark Strategy:** Because your product is a self-managed video channel, you would likely file under **Class 38** (Telecommunications / Video broadcasting) and **Class 41** (Entertainment services / Providing non-downloadable videos). You should have a clear path to registration in the US since no one is using the name in these classes.
|
||||
|
||||
## 2. Domain Name
|
||||
|
||||
Your custom SPA setup requires a strong, memorable domain. We registered `linearvoid.tv` for this purpose. The `.tv` TLD is widely recognized as a media/entertainment domain, making it ideal for your platform.
|
||||
|
||||
## 3. SEO & Market Positioning
|
||||
|
||||
**Brand Fit:**
|
||||
The name "Linear Void" is an excellent thematic fit for your project. "Linear" directly communicates the 24/7 continuous broadcast format (as opposed to VOD), while "Void" implies an independent, deep-internet, or alternative space—perfect for a platform rejecting third-party ecosystems like YouTube or Twitch.
|
||||
|
||||
**Search Engine Optimization (SEO):**
|
||||
Currently, searching for "Linear Void" brings up highly niche results (the Revit architecture plugin, medical patents, and astrophysics papers).
|
||||
|
||||
- **The Advantage:** You are not competing with any established consumer brands, pop culture entities, or other media companies.
|
||||
- **The Execution:** Because the existing search results are so strictly confined to academic and B2B engineering niches, an active video platform with decent metadata, a steady stream of traffic, and proper indexing of your HLS stream titles will very quickly overtake the front page of Google for the term "Linear Void".
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **USPTO Filing:** Prepare filing an "Intent to Use" (1B) trademark application with the USPTO for "Linear Void" under Classes 38 and 41 to lock in your rights before launch.
|
||||
+24
-119
@@ -5,133 +5,38 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void Admin</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<link rel="stylesheet" href="../src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-950 text-slate-200">
|
||||
<div class="flex h-screen" x-data="admin()">
|
||||
<!-- Sidebar -->
|
||||
<div class="w-64 bg-slate-900 p-6">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
|
||||
<div class="text-xl font-bold">Admin</div>
|
||||
<body class="admin-body">
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-sidebar" aria-label="Primary">
|
||||
<div class="brand-block">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div class="brand-text">Admin</div>
|
||||
</div>
|
||||
<nav class="space-y-2">
|
||||
<a href="#" @click="currentTab = 'dashboard'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'dashboard' }">Dashboard</a>
|
||||
<a href="#" @click="currentTab = 'upload'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</a>
|
||||
<a href="#" @click="currentTab = 'playlist'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'playlist' }">Playlist</a>
|
||||
<a href="#" @click="currentTab = 'schedule'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</a>
|
||||
<a href="#" @click="currentTab = 'moderation'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'moderation' }">Moderation</a>
|
||||
<a href="#" @click="currentTab = 'users'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</a>
|
||||
<a href="#" @click="currentTab = 'analytics'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'analytics' }">Analytics</a>
|
||||
<a href="#" @click="currentTab = 'system'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a>
|
||||
|
||||
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
||||
|
||||
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
||||
<button type="button" id="tab-trigger-dashboard" role="tab" class="tab-trigger is-active" data-tab="dashboard" aria-controls="tab-dashboard" aria-selected="true">Dashboard</button>
|
||||
<button type="button" id="tab-trigger-upload" role="tab" class="tab-trigger" data-tab="upload" aria-controls="tab-upload" aria-selected="false">Upload Video</button>
|
||||
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">Playlist</button>
|
||||
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button>
|
||||
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button>
|
||||
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">Chat Log</button>
|
||||
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button>
|
||||
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button>
|
||||
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">System</button>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 p-8 overflow-auto">
|
||||
<template x-if="currentTab === 'dashboard'">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-8">Dashboard</h1>
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-emerald-400 text-sm">Concurrent Viewers</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-blue-400 text-sm">Cache Hit Ratio</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.cacheHit + '%'">95%</div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-amber-400 text-sm">R2 Usage</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.r2Usage">12.4 GB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'upload'">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-8">Upload Video</h1>
|
||||
<div class="border-2 border-dashed border-slate-600 rounded-3xl p-12 text-center">
|
||||
<input type="file" id="video-upload" class="hidden" accept="video/*" @change="uploadVideo">
|
||||
<button onclick="document.getElementById('video-upload').click()" class="bg-blue-600 hover:bg-blue-500 px-8 py-4 rounded-2xl text-lg font-medium">
|
||||
Drag & Drop or Click to Upload
|
||||
</button>
|
||||
<div id="upload-status" class="mt-6 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'system'">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-8">System Configuration (Redis & Storage)</h1>
|
||||
<div class="bg-slate-800 p-8 rounded-3xl">
|
||||
<div class="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 class="font-medium mb-4">Redis Stats</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span>Memory Used</span>
|
||||
<span class="font-mono">248 MB</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Connected Clients</span>
|
||||
<span class="font-mono">12</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Rate Limit Threshold</span>
|
||||
<input type="number" value="10" class="bg-slate-700 px-3 py-1 rounded w-20 text-center">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium mb-4">Storage</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span>SQLite Size</span>
|
||||
<span class="font-mono">142 MB</span>
|
||||
</div>
|
||||
<button onclick="runVacuum()" class="bg-slate-700 hover:bg-slate-600 px-6 py-2 rounded-xl">Run VACUUM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function admin() {
|
||||
return {
|
||||
currentTab: 'dashboard',
|
||||
stats: {
|
||||
viewers: 142,
|
||||
cacheHit: 97,
|
||||
r2Usage: '18.4 GB'
|
||||
},
|
||||
init() {
|
||||
console.log('Admin Interface initialized (ADR-004/ADR-005)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uploadVideo(event) {
|
||||
const file = event.target.files[0];
|
||||
const status = document.getElementById('upload-status');
|
||||
status.innerHTML = `Uploading ${file.name}... (normalization in progress)`;
|
||||
setTimeout(() => {
|
||||
status.innerHTML = `<span class="text-emerald-400">✓ ${file.name} normalized and uploaded to R2</span>`;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function runVacuum() {
|
||||
alert('VACUUM executed on SQLite database (per ADR-006). Audit log entry created.');
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/admin.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void Admin Login</title>
|
||||
<link rel="stylesheet" href="../../src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="auth-body">
|
||||
<main class="auth-shell" aria-label="Admin authentication">
|
||||
<section class="auth-card" aria-labelledby="auth-title">
|
||||
<p class="auth-kicker">Linear Void</p>
|
||||
<h1 id="auth-title">Admin Login</h1>
|
||||
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
||||
|
||||
<form id="login-form" class="form-stack" novalidate>
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
||||
|
||||
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||
<p id="status" class="status-line" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('login-form');
|
||||
const username = document.getElementById('username');
|
||||
const password = document.getElementById('password');
|
||||
const submit = document.getElementById('submit');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
submit.disabled = true;
|
||||
status.textContent = 'Signing in...';
|
||||
status.className = 'status-line';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.value, password: password.value })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
window.location.href = '/admin/';
|
||||
} catch (error) {
|
||||
console.error('Failed to sign in', error);
|
||||
status.textContent = 'Login failed. Check your credentials.';
|
||||
status.className = 'status-line status-error';
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+38
-63
@@ -5,76 +5,51 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-950 text-slate-200">
|
||||
<div class="flex h-screen" x-data="app()">
|
||||
<!-- Video Player -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Linear Void</h1>
|
||||
<p class="text-xs text-emerald-400">24/7 Synchronized Channel • Live</p>
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">Hide Chat</button>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="now-playing px-4 py-1 rounded-full text-sm font-medium flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
|
||||
<span id="now-playing">Loading schedule...</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center p-8 bg-black relative">
|
||||
<video id="player" class="max-h-full rounded-xl shadow-2xl" controls autoplay></video>
|
||||
<div id="now-playing-overlay" class="absolute bottom-8 left-8 bg-black/70 backdrop-blur-md px-6 py-3 rounded-2xl text-sm hidden">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-emerald-400">● LIVE</div>
|
||||
<div id="overlay-title" class="font-medium"></div>
|
||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
<p class="chat-note">
|
||||
Synced with video • Moderation active
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-slate-900 border-t border-slate-800 text-xs text-slate-400 flex justify-between">
|
||||
<div>Latency: ~25s • Synced for all viewers (per ADR-001)</div>
|
||||
<div class="flex gap-6">
|
||||
<a href="#" onclick="switchRoom('main')" class="hover:text-white">Main Channel</a>
|
||||
<a href="#" onclick="switchRoom('chill')" class="hover:text-white">Chill Room</a>
|
||||
<a href="#" onclick="switchRoom('talk')" class="hover:text-white">Talk Room</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Sidebar -->
|
||||
<div class="w-96 flex flex-col border-l border-slate-800 bg-slate-900 chat-sidebar">
|
||||
<div class="p-4 border-b border-slate-700 font-medium flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-emerald-400">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
<button onclick="togglePrivateDM()" class="text-xs px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-lg">New DM</button>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div id="messages" class="flex-1 p-4 overflow-y-auto space-y-4 text-sm">
|
||||
<!-- Messages populated by JS -->
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="p-4 border-t border-slate-700">
|
||||
<div class="flex gap-2">
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input flex-1 px-4 py-3 rounded-2xl text-sm focus:outline-none" onkeydown="if(event.key === 'Enter') sendMessage()">
|
||||
<button onclick="sendMessage()" class="bg-blue-600 hover:bg-blue-500 px-6 rounded-2xl text-sm font-medium">Send</button>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mt-2 text-center">
|
||||
Synced with video • Moderation active
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
Generated
+5
-209
@@ -8,11 +8,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.15.12",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"hls.js": "^1.6.16",
|
||||
"postcss": "^8.5.25",
|
||||
"tailwindcss": "^4.3.3"
|
||||
"hls.js": "^1.6.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~6.0.2",
|
||||
@@ -375,131 +371,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.12",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
|
||||
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.4",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.6",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.10",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz",
|
||||
"integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.7",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"electron-to-chromium": "^1.5.393",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -510,21 +381,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.399",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz",
|
||||
"integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -543,19 +399,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -854,6 +697,7 @@
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -868,19 +712,11 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@@ -900,6 +736,7 @@
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -924,12 +761,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
|
||||
@@ -968,17 +799,12 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -1018,36 +844,6 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.2.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"update-browserslist-db": "cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.15.12",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"hls.js": "^1.6.16",
|
||||
"postcss": "^8.5.25",
|
||||
"tailwindcss": "^4.3.3"
|
||||
"hls.js": "^1.6.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Analytics</h1>
|
||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||
</div>
|
||||
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card">
|
||||
<h2>Total Messages</h2>
|
||||
<p id="analytics-total-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Last 24 Hours</h2>
|
||||
<p id="analytics-messages-24h" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Distinct Rooms</h2>
|
||||
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Top Rooms</h2>
|
||||
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Room</th>
|
||||
<th>Messages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="analytics-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Chat Log</h1>
|
||||
<p>Browse and manage stored chat messages.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-chat-log-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
<button id="bulk-delete-chat-log-btn" type="button" class="btn btn-danger" disabled>Delete Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="chat-log-filters">
|
||||
<input id="chat-log-filter-room" class="text-input" type="text" placeholder="Room" />
|
||||
<input id="chat-log-filter-username" class="text-input" type="text" placeholder="Username" />
|
||||
<input id="chat-log-filter-text" class="text-input" type="text" placeholder="Message text" />
|
||||
<button id="chat-log-filter-btn" type="button" class="btn btn-secondary">Filter</button>
|
||||
<button id="chat-log-clear-filter-btn" type="button" class="btn btn-secondary">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
||||
<th>ID</th>
|
||||
<th>Room</th>
|
||||
<th>Username</th>
|
||||
<th>Message</th>
|
||||
<th>Type</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="chat-log-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="chat-log-pagination">
|
||||
<button id="chat-log-prev-btn" type="button" class="btn btn-secondary" disabled>Previous</button>
|
||||
<span id="chat-log-page-info" class="small-muted">Page 1</span>
|
||||
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
|
||||
</div>
|
||||
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<h1>Dashboard</h1>
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card">
|
||||
<h2>Concurrent Viewers</h2>
|
||||
<p id="stat-viewers" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Stored Messages</h2>
|
||||
<p id="stat-stored-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="stat-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Media Runtime</h2>
|
||||
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
||||
<p id="stat-media-count" class="stat-caption">0 files</p>
|
||||
</article>
|
||||
</div>
|
||||
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Moderation</h1>
|
||||
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||
</div>
|
||||
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
||||
</div>
|
||||
|
||||
<div class="moderation-grid">
|
||||
<section class="panel-card">
|
||||
<h2>New Action</h2>
|
||||
<form id="moderation-form" class="form-stack">
|
||||
<label>Action
|
||||
<select id="mod-action" class="text-input">
|
||||
<option value="mute">mute</option>
|
||||
<option value="ban">ban</option>
|
||||
<option value="delete">delete</option>
|
||||
<option value="timeout">timeout</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Target
|
||||
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
||||
</label>
|
||||
<label>Moderator
|
||||
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
||||
</label>
|
||||
<label>Room
|
||||
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
||||
</label>
|
||||
<label>Reason
|
||||
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
|
||||
</label>
|
||||
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
||||
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Recent Audit Log</h2>
|
||||
<p class="small-muted">Latest entries</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Moderator</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="moderation-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Playlist</h1>
|
||||
<p>Edit the active concat playlist used by the playout service.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
|
||||
</div>
|
||||
</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>
|
||||
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
@@ -0,0 +1,54 @@
|
||||
<h1>System Configuration</h1>
|
||||
<div class="panel-card split-grid">
|
||||
<section>
|
||||
<h2>Redis Stats</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Memory Used</dt>
|
||||
<dd id="system-redis-memory">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connected Clients</dt>
|
||||
<dd id="system-redis-clients">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Redis Enabled</dt>
|
||||
<dd id="system-redis-enabled">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Storage</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Database Driver</dt>
|
||||
<dd id="system-db-driver">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stored Messages</dt>
|
||||
<dd id="system-stored-messages">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Refresh</dt>
|
||||
<dd id="system-generated-at">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Path</dt>
|
||||
<dd id="system-playlist-path">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Present</dt>
|
||||
<dd id="system-playlist-present">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Service Health</h2>
|
||||
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
||||
</div>
|
||||
<div id="system-services" class="service-list"></div>
|
||||
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Upload Video</h1>
|
||||
<p>Upload media files into the local media library mounted for playout.</p>
|
||||
</div>
|
||||
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
|
||||
</div>
|
||||
|
||||
<div id="upload-dropzone" class="upload-dropzone">
|
||||
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
|
||||
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
|
||||
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
||||
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
||||
<progress id="media-upload-progress" value="0" max="100"></progress>
|
||||
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
|
||||
</div>
|
||||
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Media Library</h2>
|
||||
<p id="media-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Title</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Size</th>
|
||||
<th>Modified</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="media-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<p>Current room presence derived from Redis presence keys.</p>
|
||||
</div>
|
||||
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Room</th>
|
||||
<th>Online</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
|
||||
</div>
|
||||
File diff suppressed because it is too large.
Load diff
+103
-70
@@ -1,28 +1,70 @@
|
||||
import Hls from "hls.js";
|
||||
import "./style.css";
|
||||
|
||||
// Main application state with Alpine.js
|
||||
type HlsClass = (typeof import("hls.js"))["default"];
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
app: () => any;
|
||||
switchRoom: (room: string) => void;
|
||||
sendMessage: () => void;
|
||||
togglePrivateDM: () => void;
|
||||
toggleChatPanel: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
let hls: Hls | null = null;
|
||||
let hls: InstanceType<HlsClass> | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let chatVisible = true;
|
||||
|
||||
function resolveWebSocketBaseUrl() {
|
||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||
|
||||
if (configuredUrl) {
|
||||
return configuredUrl;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function resolveManifestUrl() {
|
||||
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
|
||||
const cacheBust = `stream=${Date.now()}`;
|
||||
const sameOriginManifestUrl = new URL(
|
||||
"/hls/playlist.m3u8",
|
||||
window.location.origin,
|
||||
);
|
||||
sameOriginManifestUrl.searchParams.set("stream", cacheBust);
|
||||
|
||||
const allowCrossOriginManifest =
|
||||
import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() ===
|
||||
"true";
|
||||
|
||||
if (configuredUrl) {
|
||||
const manifestUrl = new URL(configuredUrl);
|
||||
manifestUrl.searchParams.set("stream", cacheBust);
|
||||
|
||||
if (
|
||||
!allowCrossOriginManifest &&
|
||||
manifestUrl.origin !== window.location.origin
|
||||
) {
|
||||
return sameOriginManifestUrl.toString();
|
||||
}
|
||||
|
||||
return manifestUrl.toString();
|
||||
}
|
||||
|
||||
return sameOriginManifestUrl.toString();
|
||||
}
|
||||
|
||||
function connectWebSocket(room: string) {
|
||||
if (ws) ws.close();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(
|
||||
`${protocol}//localhost:3000/ws?username=${username}&room=${room}`,
|
||||
);
|
||||
const socketUrl = new URL(resolveWebSocketBaseUrl());
|
||||
socketUrl.searchParams.set("username", username);
|
||||
socketUrl.searchParams.set("room", room);
|
||||
ws = new WebSocket(socketUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`Connected to chat room: ${room}`);
|
||||
@@ -48,17 +90,21 @@ function addMessage(
|
||||
) {
|
||||
const messages = document.getElementById("messages")!;
|
||||
const div = document.createElement("div");
|
||||
div.className = `message flex ${type === "system" ? "justify-center" : sender === username ? "justify-end" : "justify-start"}`;
|
||||
div.className = "message-row";
|
||||
|
||||
if (type === "system") {
|
||||
div.innerHTML = `<div class="text-xs text-slate-400 bg-slate-800 px-3 py-1 rounded-full">${text}</div>`;
|
||||
div.classList.add("message-system-row");
|
||||
div.innerHTML = `<div class="message-system">${text}</div>`;
|
||||
} else {
|
||||
div.classList.add(
|
||||
sender === username ? "message-self-row" : "message-other-row",
|
||||
);
|
||||
div.innerHTML = `
|
||||
<div class="${sender === username ? "bg-blue-600" : "bg-slate-700"} px-4 py-2 rounded-3xl max-w-[75%]">
|
||||
${sender && sender !== username ? `<div class="text-xs text-blue-300">${sender}</div>` : ""}
|
||||
<div>${text}</div>
|
||||
<div class="text-[10px] text-slate-400 mt-1">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
||||
</div>
|
||||
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
|
||||
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
|
||||
<div class="message-text">${text}</div>
|
||||
<time class="message-time">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -66,6 +112,46 @@ function addMessage(
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
async function initPlayerAndChat() {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
|
||||
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = manifestUrl;
|
||||
video.addEventListener("loadedmetadata", () => video.play());
|
||||
} else {
|
||||
try {
|
||||
const { default: Hls } = await import("hls.js");
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load HLS player", error);
|
||||
addMessage("system", "Unable to initialize video player.");
|
||||
}
|
||||
}
|
||||
|
||||
connectWebSocket("main");
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
}
|
||||
|
||||
function toggleChatPanel() {
|
||||
const shell = document.querySelector(".viewer-shell");
|
||||
const toggle = document.getElementById("chat-toggle");
|
||||
if (!shell || !toggle) return;
|
||||
|
||||
chatVisible = !chatVisible;
|
||||
shell.classList.toggle("chat-collapsed", !chatVisible);
|
||||
toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat";
|
||||
toggle.setAttribute("aria-expanded", String(chatVisible));
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
const text = input.value.trim();
|
||||
@@ -100,64 +186,11 @@ function togglePrivateDM() {
|
||||
}
|
||||
}
|
||||
|
||||
// Alpine.js app
|
||||
window.app = () => {
|
||||
return {
|
||||
init() {
|
||||
// Initialize Tailwind
|
||||
// @ts-ignore
|
||||
tailwind.config = {
|
||||
content: [],
|
||||
theme: { extend: {} },
|
||||
};
|
||||
|
||||
// Initialize HLS Player
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = "http://localhost:8080/hls/playlist.m3u8"; // from nginx/pla yout
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = manifestUrl;
|
||||
video.addEventListener("loadedmetadata", () => video.play());
|
||||
}
|
||||
|
||||
// Connect to default chat room
|
||||
connectWebSocket("main");
|
||||
|
||||
// Simulate now playing
|
||||
setInterval(() => {
|
||||
const titles = [
|
||||
"Midnight Jazz Lounge",
|
||||
"Synthwave Nights",
|
||||
"Classic Cinema",
|
||||
"Ambient Void",
|
||||
];
|
||||
document.getElementById("now-playing")!.textContent =
|
||||
titles[Math.floor(Math.random() * titles.length)];
|
||||
const overlay = document.getElementById("now-playing-overlay")!;
|
||||
overlay.classList.remove("hidden");
|
||||
document.getElementById("overlay-title")!.textContent =
|
||||
titles[Math.floor(Math.random() * titles.length)];
|
||||
}, 15000);
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Make functions global for onclick handlers
|
||||
window.switchRoom = switchRoom;
|
||||
window.sendMessage = sendMessage;
|
||||
window.togglePrivateDM = togglePrivateDM;
|
||||
window.toggleChatPanel = toggleChatPanel;
|
||||
|
||||
// Auto initialize when loaded
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// @ts-ignore
|
||||
window.app().init();
|
||||
void initPlayerAndChat();
|
||||
});
|
||||
+921
-289
File diff suppressed because it is too large.
Load diff
@@ -1,8 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
envDir: "..",
|
||||
build: {
|
||||
chunkSizeWarningLimit: 600,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: "index.html",
|
||||
admin: "admin/index.html",
|
||||
adminLogin: "admin/login/index.html",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,21 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Nginx with bundled frontend
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Create directories for HLS and frontend
|
||||
RUN mkdir -p /usr/share/nginx/html/hls /usr/share/nginx/html/admin
|
||||
# Copy prebuilt frontend from builder stage
|
||||
COPY --from=builder /build/dist/ /srv/frontend/
|
||||
|
||||
# Create HLS directory
|
||||
RUN mkdir -p /srv/hls
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -3,13 +3,69 @@ events {
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
server_name www.linearvoid.tv;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
return 301 https://linearvoid.tv$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name linearvoid.tv localhost 127.0.0.1;
|
||||
|
||||
# Resumable admin upload chunks are 8MB by default; allow safe headroom.
|
||||
client_max_body_size 64m;
|
||||
|
||||
root /srv/frontend;
|
||||
index index.html;
|
||||
|
||||
location = /admin {
|
||||
return 301 /admin/;
|
||||
}
|
||||
|
||||
location = /admin/login {
|
||||
return 301 /admin/login/;
|
||||
}
|
||||
|
||||
location = /_admin_auth {
|
||||
internal;
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream/api/admin/session;
|
||||
proxy_http_version 1.1;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /admin/login/ {
|
||||
try_files $uri $uri/ /admin/login/index.html;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
auth_request /_admin_auth;
|
||||
error_page 401 = @admin_login_redirect;
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Serve static frontend
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -17,17 +73,114 @@ http {
|
||||
|
||||
# Proxy chat WebSocket
|
||||
location /ws {
|
||||
proxy_pass http://chat:3000;
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Serve HLS from playout (with cache headers for Cloudflare)
|
||||
location = /api/admin/login {
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
}
|
||||
|
||||
location = /api/admin/logout {
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
}
|
||||
|
||||
location = /api/admin/session {
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
}
|
||||
|
||||
location /api/admin/ {
|
||||
auth_request /_admin_auth;
|
||||
error_page 401 = @admin_api_unauthorized;
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location @admin_api_unauthorized {
|
||||
add_header Content-Type application/json;
|
||||
return 401 '{"error":"unauthorized"}';
|
||||
}
|
||||
|
||||
location @admin_login_redirect {
|
||||
return 302 /admin/login/;
|
||||
}
|
||||
|
||||
location = /hls/playlist.m3u8 {
|
||||
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 {
|
||||
alias /srv/hls/init.mp4;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
location ~* ^/hls/(.+\.(ts|m4s))$ {
|
||||
alias /srv/hls/$1;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
# Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
|
||||
location /hls/ {
|
||||
alias /usr/share/nginx/html/hls/;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
alias /srv/hls/;
|
||||
add_header Cache-Control "public, max-age=60";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"title": "Linear Void Monitoring",
|
||||
"panels": [
|
||||
{
|
||||
"title": "Concurrent Viewers",
|
||||
"type": "timeseries",
|
||||
"targets": [{ "expr": "linearvoid_chat_connected_clients" }]
|
||||
},
|
||||
{
|
||||
"title": "Stored Messages",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "linearvoid_chat_messages_total"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Moderation Actions",
|
||||
"type": "timeseries",
|
||||
"targets": [{ "expr": "linearvoid_chat_moderation_actions_total" }]
|
||||
},
|
||||
{
|
||||
"title": "Redis Reachable",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "linearvoid_chat_redis_up" }]
|
||||
},
|
||||
{
|
||||
"title": "Admin Sessions",
|
||||
"type": "timeseries",
|
||||
"targets": [{ "expr": "linearvoid_chat_admin_sessions_active" }]
|
||||
},
|
||||
{
|
||||
"title": "Chat Scrape Health",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "up{job=\"chat\"}" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,10 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "chat"
|
||||
static_configs:
|
||||
- targets: ["chat:3000"]
|
||||
- job_name: "prometheus"
|
||||
static_configs:
|
||||
- targets: ["prometheus:9090"]
|
||||
Reference in new issue
Block a user