From ede0fe5467ebe443dd097203397fb272ca84c9b2 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 28 Jun 2026 14:40:54 -0400 Subject: [PATCH] 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 --- .../broadcast/infra/docker-compose.yml | 3 +- .../basic/profiles/HotelRelay/basic.ini | 30 ++++ .../basic/profiles/HotelRelay/service.json | 8 ++ .../basic-config/basic/scenes/Hotel Cam.json | 81 +++++++++++ .../broadcast/infra/scripts/bootstrap.sh | 136 ++++++++++++++++++ 5 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 codebase/@features/broadcast/infra/obs/basic-config/basic/profiles/HotelRelay/basic.ini create mode 100644 codebase/@features/broadcast/infra/obs/basic-config/basic/profiles/HotelRelay/service.json create mode 100644 codebase/@features/broadcast/infra/obs/basic-config/basic/scenes/Hotel Cam.json create mode 100755 codebase/@features/broadcast/infra/scripts/bootstrap.sh diff --git a/codebase/@features/broadcast/infra/docker-compose.yml b/codebase/@features/broadcast/infra/docker-compose.yml index 0f9f5e64..9ae62eec 100644 --- a/codebase/@features/broadcast/infra/docker-compose.yml +++ b/codebase/@features/broadcast/infra/docker-compose.yml @@ -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). # diff --git a/codebase/@features/broadcast/infra/obs/basic-config/basic/profiles/HotelRelay/basic.ini b/codebase/@features/broadcast/infra/obs/basic-config/basic/profiles/HotelRelay/basic.ini new file mode 100644 index 00000000..da5dafba --- /dev/null +++ b/codebase/@features/broadcast/infra/obs/basic-config/basic/profiles/HotelRelay/basic.ini @@ -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 diff --git a/codebase/@features/broadcast/infra/obs/basic-config/basic/profiles/HotelRelay/service.json b/codebase/@features/broadcast/infra/obs/basic-config/basic/profiles/HotelRelay/service.json new file mode 100644 index 00000000..3460d852 --- /dev/null +++ b/codebase/@features/broadcast/infra/obs/basic-config/basic/profiles/HotelRelay/service.json @@ -0,0 +1,8 @@ +{ + "type": "rtmp_custom", + "settings": { + "server": "rtmp://127.0.0.1:1935/live", + "key": "produced", + "use_auth": false + } +} diff --git a/codebase/@features/broadcast/infra/obs/basic-config/basic/scenes/Hotel Cam.json b/codebase/@features/broadcast/infra/obs/basic-config/basic/scenes/Hotel Cam.json new file mode 100644 index 00000000..482f1813 --- /dev/null +++ b/codebase/@features/broadcast/infra/obs/basic-config/basic/scenes/Hotel Cam.json @@ -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": [] +} diff --git a/codebase/@features/broadcast/infra/scripts/bootstrap.sh b/codebase/@features/broadcast/infra/scripts/bootstrap.sh new file mode 100755 index 00000000..8d83ae21 --- /dev/null +++ b/codebase/@features/broadcast/infra/scripts/bootstrap.sh @@ -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."