Live portal: Go Live — browser window capture published over WHIP as <user>/HelmetCam-<date>; dumpd /me/stream-key

Go Live closes viewer windows, fetches the Authentik-identified user's MediaMTX key from dumpd
(/etc/vanguard/streamers.json written by configure.py), opens Chrome's window-only picker with
audio, prefers H.264 so MediaMTX can record, caps at 12 Mbps / 1440p60, shows live stats, Stop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 23:04:33 -05:00
parent 463d9b2385
commit 82df0b1583
11 changed files with 258 additions and 10 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ __pycache__/
.env
bot/app/__pycache__/
*.pyc
streamers.json

View File

@@ -198,6 +198,7 @@ Checklist:
- [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity
### Phase 7 — dock polish + cleanup
(partly done: dumpd `/me/stream-key` exists and the live portal's Go Live uses it; the OBS dock still holds its own copy of the key)
- [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer;
dock fills OBS settings from it — nobody types a stream password again
- [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik)

View File

@@ -199,6 +199,10 @@ asked by `configure.py --full`) can post when a stream starts.
embedded outpost → group policy (e.g. everyone in the server, or `streamers`+`editors`+`admins`).
3. Dev box: `./deployLive.sh` builds `live/` and copies `dist/` to `/srv/live` (Caddy serves it directly).
**Go Live** button: broadcast a window from the browser (no OBS) as `<user>/HelmetCam-<date>` — key comes
from `/me/stream-key` (dumpd reads `/etc/vanguard/streamers.json`, identity from the Authentik header), so a
streamer's Authentik username must equal their MediaMTX name.
Bandwidth: no transcoding, every viewer gets the source bitrate (~15 Mbps) → ~30 concurrent viewers on
the 500 Mbps line. For a crowd, restream to Twitch from OBS.

View File

@@ -31,6 +31,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Editor | Mount the library as a drive | WebDAV, same URL | same |
| Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) |
| Anyone in the Discord server | Watch live: all active streams in dockable windows, audio follows focus, per-window mute/volume | Live portal `https://live.…/` | Discord (Authentik) — **pending deploy** |
| Streamer | **Go Live** from the browser: pick the WarDogs window (+audio), streams as `<user>/HelmetCam-<date>`; no OBS needed | Live portal → Go Live | Discord; key fetched for the logged-in user — **pending deploy** |
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) |
| Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists |
| Admin | Add/remove streamers & editors, hostnames, bot token | `./configure.py` | ssh |
@@ -58,7 +59,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| act_runner | CI jobs in Docker | **not registered** |
| discord-bot | Python runtime, idles until `bot/app/main.py` exists | live, no code yet |
| `bot.` host | bot web UI behind Authentik forward-auth | Caddy block ready; **CNAME + Authentik proxy provider pending** |
| Live portal (`live/`, `live.` host) | React + dockview multi-stream viewer; MediaMTX HLS/WebRTC/API re-enabled on loopback | **built; CNAME, Authentik proxy provider, `./deployLive.sh`, configure apply pending** |
| Live portal (`live/`, `live.` host) | React + dockview multi-stream viewer + browser Go Live (WHIP); MediaMTX HLS/WebRTC/API re-enabled on loopback; dumpd `/me/stream-key` | **built; CNAME, Authentik proxy provider, `./deployLive.sh`, configure apply pending** |
## Unfinished, in rough priority

View File

@@ -19,6 +19,8 @@ SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
TRASH = ROOT / "trash"
MARK = ROOT / ".markers"
USERS_FILE = Path(os.environ.get("DUMP_USERS", "/etc/vanguard/dump-users.txt"))
STREAMERS_FILE = Path(os.environ.get("STREAMERS_FILE", "/etc/vanguard/streamers.json")) # {"techy": "pass", ...} written by configure.py
AUTH_USER_HEADER = os.environ.get("AUTH_USER_HEADER", "X-Authentik-Username")
LISTEN = ("127.0.0.1", 8080)
TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
NIGHT_START = time(17, 0) # 5 pm
@@ -92,6 +94,12 @@ def do_move(files, night):
d.rmdir()
return moved, size
def load_streamers():
try:
return json.loads(STREAMERS_FILE.read_text())
except (OSError, json.JSONDecodeError):
return {}
class H(BaseHTTPRequestHandler):
server_version = "dumpd/1"
def _json(self, code, obj):
@@ -129,6 +137,17 @@ class H(BaseHTTPRequestHandler):
return req_user or "all"
return auth_user
def do_GET(self):
if self.path.startswith("/me/stream-key"):
# Only reachable through Caddy behind Authentik forward-auth, which injects the username header.
# dumpd binds to loopback, so a request carrying this header is one Caddy already authenticated.
who = (self.headers.get(AUTH_USER_HEADER) or "").strip().lower()
if not who:
return self._json(401, {"error": "not authenticated"})
streamers = load_streamers()
pw = streamers.get(who)
if not pw:
return self._json(403, {"error": f"no stream key for '{who}' — ask the admin to add you as a streamer with that exact name"})
return self._json(200, {"user": who, "pass": pw})
if not self.path.startswith("/dump"):
return self._json(404, {"error": "not found"})
u = check_auth(self.headers.get("Authorization"))

View File

@@ -141,7 +141,10 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
# vanguard:live
{live_host} {{
encode zstd gzip
{AUTHENTIK_FORWARD_AUTH} handle_path /api/* {{
{AUTHENTIK_FORWARD_AUTH} handle /me/* {{
reverse_proxy 127.0.0.1:8080 # dumpd: /me/stream-key for the logged-in streamer (Go Live)
}}
handle_path /api/* {{
reverse_proxy 127.0.0.1:9997
}}
handle_path /webrtc/* {{
@@ -362,6 +365,10 @@ def write_copyparty(admin_pw, editors):
rwmd: admin
""")
def write_streamers_json(streamers):
import json as _json
Path("streamers.json").write_text(_json.dumps({u.lower(): p for u, p in streamers.items()}, indent=2) + "\n")
def write_dump_users(admin_pw, streamers):
h = lambda p: hashlib.sha256(p.encode()).hexdigest()
body = "# user:sha256hex(password) (generated by configure.py)\n"
@@ -376,7 +383,7 @@ def run(cmd, **kw):
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", live_webhook=None):
print("\n== writing configs")
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(streamers); write_copyparty(admin_pw, editors); write_dump_users(admin_pw, streamers)
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(streamers); write_copyparty(admin_pw, editors); write_dump_users(admin_pw, streamers); write_streamers_json(streamers)
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook)
run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
print("== app data folders")
@@ -392,6 +399,7 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
print("== dump users + restart dumpd")
run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"])
run(["sudo", "install", "-m", "0640", "dump-users.txt", "/etc/vanguard/dump-users.txt"])
run(["sudo", "install", "-m", "0600", "streamers.json", "/etc/vanguard/streamers.json"])
run(["sudo", "systemctl", "restart", "dumpd"])
print("== containers")
dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"]

View File

@@ -16,3 +16,12 @@ silences everything until you focus a window; "Follow focus" clears pins.
Endpoints (proxied by Caddy, all behind the Authentik gate):
`/api/*` → MediaMTX API 9997 · `/webrtc/*` → WebRTC/WHEP 8889 · `/hls/*` → HLS 8888.
## Go Live (browser broadcast)
Toolbar → **Go Live**: closes all viewer windows, fetches the logged-in user's stream key from
`/me/stream-key` (dumpd, identity from Authentik's header), opens Chrome's window picker (windows only,
audio requested), and publishes over WebRTC/WHIP to `<user>/HelmetCam-<date>` at ≤12 Mbps, H.264, up to
1440p60. Recording, the "is live" post, the sweep and Jellyfin follow exactly as for an OBS stream.
Window audio: Chrome on Windows. Linux/Mac: pick a tab or the whole screen for sound.
Requires the Authentik username to equal the MediaMTX streamer name (configure.py keeps them in sync via
`/etc/vanguard/streamers.json`).

View File

@@ -3,6 +3,7 @@ import { DockviewReact } from 'dockview-react'
import Player from './Player.jsx'
import { fetchStreams } from './api.js'
import { audio } from './useAudio.js'
import GoLive from './GoLive.jsx'
const components = { player: Player }
const POLL_MS = 3000
@@ -11,6 +12,7 @@ export default function App() {
const apiRef = useRef(null)
const [streams, setStreams] = useState([])
const [err, setErr] = useState(null)
const [mode, setMode] = useState('watch') // watch | golive
const focused = audio.use(s => s.focused)
// poll MediaMTX; open/close panels to match
@@ -20,7 +22,7 @@ export default function App() {
try {
const list = await fetchStreams(); if (!alive) return
setErr(null); setStreams(list)
const api = apiRef.current; if (!api) return
const api = apiRef.current; if (!api) return // (in golive mode the dock is unmounted: no viewer connections)
const have = new Set(api.panels.map(p => p.id))
const want = new Set(list.map(s => s.id))
for (const s of list) {
@@ -56,13 +58,18 @@ export default function App() {
<span className="count">audio follows the focused window</span>
<button onClick={() => audio.muteAll()} title="mute every window until you click one">Mute all</button>
<button onClick={() => audio.followFocus()} title="clear pinned audio; only the focused window plays sound">Follow focus</button>
<button className="golive-btn" onClick={() => { audio.muteAll(); apiRef.current = null; setMode('golive') }} title="stop watching and broadcast a window from this browser">Go Live</button>
</div>
{streams.length === 0 && !err && (
<div className="empty"><h2>Nobody is live right now</h2><span>This page updates itself when a stream starts.</span></div>
)}
<div className="dock" style={{ display: streams.length ? 'block' : 'none' }}>
<DockviewReact className="dockview-theme-vanguard" components={components} onReady={onReady} />
</div>
{mode === 'golive' ? (
<GoLive onExit={() => { audio.followFocus(); setMode('watch') }} />
) : (<>
{streams.length === 0 && !err && (
<div className="empty"><h2>Nobody is live right now</h2><span>This page updates itself when a stream starts.</span></div>
)}
<div className="dock" style={{ display: streams.length ? 'block' : 'none' }}>
<DockviewReact className="dockview-theme-vanguard" components={components} onReady={onReady} />
</div>
</>)}
</div>
)
}

89
live/src/GoLive.jsx Normal file
View File

@@ -0,0 +1,89 @@
import React, { useEffect, useRef, useState } from 'react'
import { captureWindow, publish, fetchStreamKey, defaultTitle, cleanTitle, MAX_KBPS } from './broadcast.js'
// Broadcaster view: replaces the viewer grid while you're live.
export default function GoLive({ onExit }) {
const videoRef = useRef(null)
const sessionRef = useRef(null)
const [phase, setPhase] = useState('setup') // setup | capturing | live | error
const [title, setTitle] = useState(defaultTitle())
const [key, setKey] = useState(null)
const [err, setErr] = useState('')
const [conn, setConn] = useState('')
const [stats, setStats] = useState(null)
const [started, setStarted] = useState(null)
const [elapsed, setElapsed] = useState(0)
const [hasAudio, setHasAudio] = useState(true)
useEffect(() => { fetchStreamKey().then(setKey).catch(e => { setErr(e.message); setPhase('error') }) }, [])
useEffect(() => { if (!started) return; const t = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000); return () => clearInterval(t) }, [started])
useEffect(() => {
if (phase !== 'live') return
let last = { bytes: 0, t: Date.now() }
const t = setInterval(async () => {
const pc = sessionRef.current?.pc; if (!pc) return
for (const s of await pc.getStats()) if (s.type === 'outbound-rtp' && s.kind === 'video') {
const now = Date.now(); const kbps = Math.round((s.bytesSent - last.bytes) * 8 / ((now - last.t) || 1)); last = { bytes: s.bytesSent, t: now }
setStats({ kbps, fps: Math.round(s.framesPerSecond || 0), w: s.frameWidth || 0, h: s.frameHeight || 0, q: s.qualityLimitationReason })
}
}, 1000)
return () => clearInterval(t)
}, [phase])
useEffect(() => () => { sessionRef.current?.stop() }, [])
const start = async () => {
setErr('')
const t = cleanTitle(title); if (!t) return setErr('give it a title')
if (!key) return setErr('no stream key')
try {
setPhase('capturing')
const stream = await captureWindow()
setHasAudio(stream.getAudioTracks().length > 0)
if (videoRef.current) { videoRef.current.srcObject = stream; videoRef.current.muted = true; videoRef.current.play().catch(() => {}) }
stream.getVideoTracks()[0].onended = () => stop() // user clicked Chrome's "Stop sharing"
const session = await publish({ stream, path: `${key.user}/${t}`, user: key.user, pass: key.pass, onState: setConn })
sessionRef.current = session
setStarted(Date.now()); setPhase('live')
} catch (e) { setErr(e.message); setPhase(e.message === 'capture cancelled' ? 'setup' : 'error') }
}
const stop = async () => { await sessionRef.current?.stop(); sessionRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; setPhase('setup'); setStarted(null); setStats(null); setConn('') }
const mmss = s => `${String(Math.floor(s / 3600)).padStart(2, '0')}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
return (
<div className="golive">
<div className="golive-head">
<h2>{phase === 'live' ? '🔴 You are live' : 'Go live from this browser'}</h2>
<span className="who">{key ? `as ${key.user}` : ''}</span>
<span className="spacer" />
{phase === 'live' ? <button className="danger" onClick={stop}>Stop stream</button>
: <button onClick={() => { sessionRef.current?.stop(); onExit() }}>Back to watching</button>}
</div>
<div className="golive-body">
<video ref={videoRef} autoPlay playsInline muted className={phase === 'live' || phase === 'capturing' ? 'show' : ''} />
{phase !== 'live' && (
<div className="golive-form">
<label>Stream title <small>(becomes the folder and the Jellyfin title)</small></label>
<input value={title} onChange={e => setTitle(e.target.value)} disabled={phase === 'capturing'} />
<div className="hint">
Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>.
Window audio works in Chrome on Windows; on Linux/Mac pick a tab or the whole screen for sound.
Capped at {MAX_KBPS / 1000} Mbps, up to 1440p60, H.264.
</div>
{err && <div className="err">{err}</div>}
<button className="primary" disabled={!key || phase === 'capturing'} onClick={start}>{phase === 'capturing' ? 'Waiting for the window picker…' : 'Start'}</button>
</div>
)}
{phase === 'live' && (
<div className="golive-stats">
<span className="pill live">LIVE</span>
<span>{mmss(elapsed)}</span>
<span>{key.user}/{cleanTitle(title)}</span>
<span>{conn}</span>
{stats && <span>{stats.w}×{stats.h} · {stats.fps} fps · {(stats.kbps / 1000).toFixed(1)} Mbps{stats.q && stats.q !== 'none' ? ` · limited by ${stats.q}` : ''}</span>}
{!hasAudio && <span className="warn">no audio captured — restart and tick "Share audio"</span>}
</div>
)}
</div>
</div>
)
}

86
live/src/broadcast.js Normal file
View File

@@ -0,0 +1,86 @@
// Publish a captured window to MediaMTX over WHIP (WebRTC), through Caddy at /webrtc/<path>/whip.
// Credentials go as query params so the Authentik forward-auth in front doesn't see an Authorization header.
export const MAX_KBPS = 12000
export function defaultTitle(now = new Date()) {
const d = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10)
return `HelmetCam-${d}`
}
export const cleanTitle = t => t.trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9_.-]/g, '')
export async function captureWindow() {
// Window-only picker with audio. Chrome on Windows captures that window's audio; elsewhere the user
// should pick a tab or the whole screen to get sound.
const constraints = {
video: { displaySurface: 'window', frameRate: { ideal: 60, max: 60 }, width: { max: 2560 }, height: { max: 1440 } },
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, sampleRate: 48000, channelCount: 2 },
systemAudio: 'include',
selfBrowserSurface: 'exclude',
monitorTypeSurfaces: 'exclude', // hide "Entire screen" so people pick the game window
surfaceSwitching: 'include',
preferCurrentTab: false,
}
try {
return await navigator.mediaDevices.getDisplayMedia(constraints)
} catch (e) {
if (e.name === 'NotAllowedError') throw new Error('capture cancelled')
// older browsers choke on the newer hints; retry plain
return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 60 }, audio: true })
}
}
function preferH264(pc, kind = 'video') {
const caps = RTCRtpSender.getCapabilities?.(kind)
if (!caps) return
const tx = pc.getTransceivers().find(t => t.sender.track?.kind === kind)
if (!tx?.setCodecPreferences) return
const h264 = caps.codecs.filter(c => /H264/i.test(c.mimeType) && /packetization-mode=1/.test(c.sdpFmtpLine || ''))
const rest = caps.codecs.filter(c => !/H264/i.test(c.mimeType))
if (h264.length) tx.setCodecPreferences([...h264, ...rest]) // MediaMTX records H264 into fMP4; VP8 could not be recorded
}
export async function publish({ stream, path, user, pass, onState }) {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
for (const track of stream.getTracks()) pc.addTransceiver(track, { direction: 'sendonly' })
preferH264(pc)
const vs = pc.getSenders().find(s => s.track?.kind === 'video')
if (vs) {
const p = vs.getParameters(); p.encodings = p.encodings?.length ? p.encodings : [{}]
p.encodings[0].maxBitrate = MAX_KBPS * 1000
p.encodings[0].maxFramerate = 60
p.degradationPreference = 'maintain-framerate'
await vs.setParameters(p).catch(() => {})
}
pc.onconnectionstatechange = () => onState?.(pc.connectionState)
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
await new Promise(res => { if (pc.iceGatheringState === 'complete') return res(); const t = setTimeout(res, 2000); pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res() } } })
const url = `/webrtc/${path}/whip?user=${encodeURIComponent(user)}&pass=${encodeURIComponent(pass)}`
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
if (!r.ok) { pc.close(); throw new Error(r.status === 401 || r.status === 403 ? 'stream key rejected' : r.status === 400 && /busy|already/i.test(await r.text().catch(() => '')) ? 'that stream name is already live (OBS running?)' : `publish failed (${r.status})`) }
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
const session = r.headers.get('Location')
return {
pc,
stats: async () => {
let kbps = 0, fps = 0, w = 0, h = 0
for (const s of await pc.getStats()) {
if (s.type === 'outbound-rtp' && s.kind === 'video') { fps = s.framesPerSecond || 0; w = s.frameWidth || 0; h = s.frameHeight || 0; kbps = s.__k || 0 }
}
return { kbps, fps, w, h }
},
stop: async () => {
try { if (session) await fetch(session.startsWith('http') ? new URL(session).pathname : session, { method: 'DELETE', credentials: 'same-origin' }) } catch {}
pc.close()
stream.getTracks().forEach(t => t.stop())
},
}
}
export async function fetchStreamKey() {
const r = await fetch('/me/stream-key', { credentials: 'same-origin' })
const body = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(body.error || `stream key ${r.status}`)
return body
}

View File

@@ -30,3 +30,26 @@ html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg)
.player.focused .bar { background:#1c2333; }
.empty { flex:1; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:8px; color:var(--mut); }
.empty h2 { color:var(--fg); margin:0; font-size:20px; }
/* --- Go Live --------------------------------------------------------------------------------- */
.golive { flex:1; display:flex; flex-direction:column; min-height:0; }
.golive-head { display:flex; align-items:center; gap:12px; padding:10px 14px; background:#101010; border-bottom:1px solid #2a2a2a; }
.golive-head h2 { margin:0; font-size:16px; }
.golive-head .who { color:var(--mut); }
.golive-head .spacer { flex:1; }
.golive-head button, .golive-form button { background:#2a2a2a; color:var(--fg); border:1px solid #3a3a3a; border-radius:6px; padding:8px 14px; cursor:pointer; font-size:14px; }
.golive-head button.danger { background:var(--live); border-color:var(--live); color:#fff; font-weight:600; }
.golive-form button.primary { background:var(--acc); border-color:var(--acc); color:#fff; font-weight:600; width:100%; padding:12px; margin-top:8px; }
.golive-form button:disabled { opacity:.5; cursor:default; }
.golive-body { flex:1; position:relative; display:flex; align-items:center; justify-content:center; min-height:0; background:#000; }
.golive-body video { display:none; width:100%; height:100%; object-fit:contain; }
.golive-body video.show { display:block; }
.golive-form { position:absolute; width:min(520px, calc(100% - 32px)); background:var(--panel); border:1px solid #3a3a3a; border-radius:12px; padding:22px; display:grid; gap:10px; }
.golive-form label { color:var(--mut); font-size:13px; }
.golive-form input { padding:10px 12px; font-size:15px; background:#141414; color:var(--fg); border:1px solid #444; border-radius:8px; }
.golive-form .hint { color:var(--mut); font-size:13px; line-height:1.5; }
.golive-form .err { color:var(--live); font-size:13px; }
.golive-stats { position:absolute; left:12px; bottom:12px; display:flex; gap:14px; align-items:center; background:rgba(0,0,0,.65); padding:8px 12px; border-radius:8px; font-size:13px; }
.golive-stats .pill.live { background:var(--live); color:#fff; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; }
.golive-stats .warn { color:#f1c40f; }
.topbar button.golive-btn { background:var(--live); border-color:var(--live); color:#fff; font-weight:700; }