diff --git a/.gitignore b/.gitignore index 4d32d07..d34d566 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .env bot/app/__pycache__/ *.pyc +streamers.json diff --git a/CommunityDiscordMedia.md b/CommunityDiscordMedia.md index ac2f5fb..2d61f58 100644 --- a/CommunityDiscordMedia.md +++ b/CommunityDiscordMedia.md @@ -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) diff --git a/README.md b/README.md index 1c1fb77..68a88e2 100644 --- a/README.md +++ b/README.md @@ -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 `/HelmetCam-` — 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. diff --git a/STATUS.md b/STATUS.md index 2882297..3503f9b 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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 `/HelmetCam-`; 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 diff --git a/bin/dumpd.py b/bin/dumpd.py index f18a80a..b85ff65 100755 --- a/bin/dumpd.py +++ b/bin/dumpd.py @@ -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")) diff --git a/configure.py b/configure.py index 43e0fa8..e7e5171 100755 --- a/configure.py +++ b/configure.py @@ -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"] diff --git a/live/README.md b/live/README.md index 906c3c3..4761422 100644 --- a/live/README.md +++ b/live/README.md @@ -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 `/HelmetCam-` 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`). diff --git a/live/src/App.jsx b/live/src/App.jsx index e69f864..52564e3 100644 --- a/live/src/App.jsx +++ b/live/src/App.jsx @@ -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() { audio follows the focused window + - {streams.length === 0 && !err && ( -

Nobody is live right now

This page updates itself when a stream starts.
- )} -
- -
+ {mode === 'golive' ? ( + { audio.followFocus(); setMode('watch') }} /> + ) : (<> + {streams.length === 0 && !err && ( +

Nobody is live right now

This page updates itself when a stream starts.
+ )} +
+ +
+ )} ) } diff --git a/live/src/GoLive.jsx b/live/src/GoLive.jsx new file mode 100644 index 0000000..2bbb101 --- /dev/null +++ b/live/src/GoLive.jsx @@ -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 ( +
+
+

{phase === 'live' ? '🔴 You are live' : 'Go live from this browser'}

+ {key ? `as ${key.user}` : ''} + + {phase === 'live' ? + : } +
+
+
+
+ ) +} diff --git a/live/src/broadcast.js b/live/src/broadcast.js new file mode 100644 index 0000000..6b9b0b4 --- /dev/null +++ b/live/src/broadcast.js @@ -0,0 +1,86 @@ +// Publish a captured window to MediaMTX over WHIP (WebRTC), through Caddy at /webrtc//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 +} diff --git a/live/src/styles.css b/live/src/styles.css index a76d0b5..73d250a 100644 --- a/live/src/styles.css +++ b/live/src/styles.css @@ -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; }