feat(broadcast): add production relay infra under feature (compose, mediamtx, custom OBS Dockerfile+seed for Hotel Cam/LowerThird/produced RTMP, audio+video bridges, bootstrap, ufw/health/fanout)

- docker-compose with all services, health, depends, obs-config volume seed
- mediamtx with live + live/produced paths
- obs/ bakes defaults so start_broadcast + set_text + v4l2/alsa just work
- bootstrap.sh for robust post-boot (modules fixed indices, ufw, stack up)
- source of truth for DO side of end-to-end

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-28 14:40:54 -04:00
parent b744ff19f0
commit ede0fe5467
5 changed files with 256 additions and 2 deletions

View file

@ -1,6 +1,5 @@
version: "3.8"
# Production-ready relay stack for quinn.cast / broadcast.
# (no top-level version; modern compose ignores the legacy key)
# mediamtx (SRT ingest + RTMP for OBS produced) + v4l2/audio bridges + OBS (seeded) + LLM controller.
# Run on DO droplet after provision-stream-droplet.sh (which deploys these files).
#

View file

@ -0,0 +1,30 @@
[Output]
Mode=Advanced
[AdvOut]
RecType=Standard
RecEncoder=x264
RecResType=1
RecRateControl=CBR
RecBitrate=6000
RecKeyframeInterval=2
Track1Bitrate=160
Track1Name=Track1
StreamEncoder=x264
StreamResType=1
StreamRes=1280x720
StreamRateControl=CBR
StreamBitrate=6000
StreamKeyframeInterval=2
[Video]
BaseCX=1280
BaseCY=720
OutputCX=1280
OutputCY=720
FPSType=0
FPSCommon=30
[Audio]
SampleRate=48000
ChannelSetup=Stereo

View file

@ -0,0 +1,8 @@
{
"type": "rtmp_custom",
"settings": {
"server": "rtmp://127.0.0.1:1935/live",
"key": "produced",
"use_auth": false
}
}

View file

@ -0,0 +1,81 @@
{
"current_program_scene": "Hotel Cam",
"current_scene": "Hotel Cam",
"modules": {},
"name": "Hotel Cam",
"scene_order": [
{
"name": "Hotel Cam"
}
],
"scenes": [
{
"hotkeys": {},
"name": "Hotel Cam",
"sources": [
{
"hotkeys": {},
"name": "Hotel Feed (V4L2)",
"private_settings": {},
"settings": {
"device": "/dev/video10",
"input": 0,
"pixelformat": 0,
"resolution": "1280x720",
"framerate": 30
},
"versioned_id": "v4l2_input",
"visible": true,
"volume": 1.0,
"x": 0,
"y": 0,
"cx": 1280,
"cy": 720
},
{
"hotkeys": {},
"name": "Hotel Mic",
"private_settings": {},
"settings": {
"device": "hw:2,0,0"
},
"versioned_id": "alsa_input_capture",
"visible": true,
"volume": 1.0
},
{
"hotkeys": {},
"name": "LowerThird",
"private_settings": {},
"settings": {
"color": 16777215,
"font": {
"face": "Arial",
"flags": 0,
"size": 48,
"style": ""
},
"height": 100,
"text": "Live from the road — rates in profile",
"width": 900,
"outline": true,
"outline_color": 0,
"outline_size": 2
},
"versioned_id": "text_ft2_source_v2",
"visible": true,
"volume": 1.0,
"x": 40,
"y": 620,
"cx": 900,
"cy": 80
}
],
"transition_duration": 300,
"transition": "Fade"
}
],
"sources": [],
"transitions": [],
"groups": []
}

View file

@ -0,0 +1,136 @@
#!/bin/bash
# bootstrap.sh — robust post-boot / first-run setup for the quinn.cast relay droplet.
# Idempotent. Run as root.
#
# This is the "make it work" piece for the DO side:
# - kernel modules (v4l2loopback + snd-aloop for video+audio bridges)
# - docker + compose v2
# - ufw with sensible rules for SRT (public) + UI (consider further restrict) + SSH
# - layout of /opt/stream with the deployed infra/ files symlinked/copied
# - .env from example if missing
# - start the stack
#
# Called automatically by provision-stream-droplet.sh create (after scp of infra+controller).
# Also usable standalone after you scp the infra/ tree yourself:
# scp -r .../infra root@IP:/opt/stream/infra
# ssh root@IP 'bash /opt/stream/infra/scripts/bootstrap.sh'
#
# After: edit /opt/stream/.env , then the stack is up.
# The controller UI will be at http://IP:8080/?p=THE_PASSPHRASE
#
# Cost control: when done, doctl compute droplet delete NAME -f (or power off to stop billing transfer).
set -euo pipefail
echo "==> [bootstrap] quinn.cast relay stack setup (robust production mode)"
echo "==> System update + essentials (docker, ufw, audio/video loopback support)"
apt-get update -y
apt-get install -y \
docker.io \
docker-compose-v2 \
ufw \
v4l-utils \
alsa-utils \
curl \
jq \
htop \
ca-certificates
# Use docker compose v2 plugin (space, not hyphen)
if ! command -v "docker compose" >/dev/null 2>&1; then
echo "docker compose v2 not found after install; trying alternatives"
ln -sf /usr/libexec/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose || true
fi
echo "==> Ensure docker daemon"
systemctl enable --now docker || true
docker --version
docker compose version || true
echo "==> v4l2loopback (video device for OBS)"
modprobe v4l2loopback devices=1 video_nr=10 card_label="RemoteFeed" exclusive_caps=1 || true
echo 'options v4l2loopback devices=1 video_nr=10 card_label="RemoteFeed" exclusive_caps=1' > /etc/modprobe.d/v4l2loopback.conf || true
echo "==> snd-aloop (audio loopback for virtual mic into OBS)"
echo 'options snd-aloop index=2,3' > /etc/modprobe.d/snd-aloop.conf || true
modprobe snd-aloop || true
echo "==> Show audio cards (for debugging the 'Hotel Mic' ALSA device if silent)"
cat /proc/asound/cards || aplay -l || true
echo " (If 'Hotel Mic' is silent in the seeded scene, use a webtop one-time or arecord -l to pick correct hw:xx and update the source settings in OBS.)"
echo "==> ufw firewall (SRT UDP must be open; UI 8080 should be restricted in prod)"
ufw default deny incoming || true
ufw default allow outgoing || true
ufw allow 22/tcp || true
ufw allow 8080/tcp || true # LLM controller UI (gate with passphrase; put real TLS/domain in front soon)
ufw allow 8890/udp || true # SRT ingest (public by design; hotel push targets this)
ufw allow 1935/tcp || true # RTMP (local produced from OBS; also optional public ingest)
ufw allow 4455/tcp || true # obs-websocket (internal to compose; do not expose publicly)
ufw allow 8554/tcp || true # rtsp (optional)
ufw allow 8889/tcp || true # webrtc (optional)
ufw --force enable || true
ufw status verbose || true
echo "==> Prepare /opt/stream layout (infra/ was scp'ed here by provision or manually)"
mkdir -p /opt/stream
cd /opt/stream
# Make the deployed files the active ones (idempotent links/copies)
if [ -d infra ]; then
ln -sfn infra/docker-compose.yml docker-compose.yml 2>/dev/null || cp -f infra/docker-compose.yml .
ln -sfn infra/.env.example .env.example 2>/dev/null || cp -f infra/.env.example .
mkdir -p mediamtx obs
cp -rf infra/mediamtx/* mediamtx/ 2>/dev/null || true
cp -rf infra/obs/* obs/ 2>/dev/null || true
echo " infra/ files activated"
else
echo " WARNING: no infra/ subdir found — you must scp it before bootstrap or use the create flow"
fi
echo "==> .env setup (edit this!)"
if [ ! -f .env ]; then
if [ -f .env.example ]; then
cp .env.example .env
else
cat > .env <<'E'
XAI_API_KEY=sk-replace-me
OBS_WS_PASSWORD=replace-me
UI_PASSPHRASE=replace-me
E
fi
echo " Created .env from example — EDIT IT NOW with real XAI key + strong passwords"
else
echo " .env already present (leaving it)"
fi
echo "==> Pull / build and start the stack (controller will build from ./controller which must also be present)"
docker compose pull mediamtx video-bridge audio-bridge || true
docker compose build --pull controller obs || true
docker compose up -d
echo "==> Stack status"
docker compose ps
echo "==> Quick health"
curl -fsS http://localhost:8080/health || echo "(controller health not yet ready — normal on first boot; give it 20s and retry)"
curl -fsS http://localhost:9997/v3/paths/list | head -c 200 || echo "(mediamtx api not responding yet)"
echo ""
echo "==> DONE. Next:"
echo " 1. Edit /opt/stream/.env with your real XAI_API_KEY (from console.x.ai), strong OBS_WS_PASSWORD, UI_PASSPHRASE"
echo " 2. docker compose restart controller (or up -d again)"
echo " 3. From hotel: ./scripts/hotel-srt-push.sh --target YOUR_DROPLET_IP:8890 --bitrate 3500"
echo " 4. Open http://YOUR_DROPLET_IP:8080/?p=YOUR_UI_PASSPHRASE and chat e.g. 'what is the status?'"
echo " 5. 'start broadcast' once you have the hotel feed and at least one destination added."
echo ""
echo " Optional but recommended soon:"
echo " - Add a domain + Caddy/TLS in front of 8080 (or use WG mesh for UI only)"
echo " - Set a strong srtPublishPassphrase in mediamtx/mediamtx.yml and update your hotel push URL"
echo " - Monitor DO bandwidth / cost; destroy droplet when done: doctl compute droplet delete NAME -f"
echo ""
echo " ufw is enabled; 8080 is open to world (protected by passphrase). Restrict via DO firewall or"
echo " 'ufw allow from YOUR_LAPTOP_IP to any port 8080' if you want belt-and-suspenders."
echo ""
echo "We run real systems."