Compare commits

..

2 Commits

Author SHA1 Message Date
82df0b1583 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>
2026-10-30 23:04:33 -05:00
463d9b2385 Live portal: React + dockview multi-stream viewer behind Authentik; MediaMTX HLS/WebRTC/API re-enabled; is-live Discord hook
live/: every active stream opens as a dockable window (WHEP, HLS fallback), audio follows the
focused window, per-window mute/volume, pinning. Caddy live.HOST block with forward-auth proxies
/api /webrtc /hls to MediaMTX on loopback and serves /srv/live. WebRTC media on 8189 (firewall).
configure.py asks the live hostname and an optional Discord webhook; MediaMTX -ffmpeg image runs
the runOnReady hook. deployLive.sh builds and ships dist/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 23:01:11 -05:00
27 changed files with 2646 additions and 28 deletions

View File

@@ -13,3 +13,6 @@ GIT_HOST=git.example.com
GITEA_DB_PASSWORD=
GITEA_RUNNER_TOKEN=
BOT_HOST=bot.example.com
LIVE_HOST=live.example.com
MAIN_HOST=media.example.com
LIVE_WEBHOOK_URL=

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

@@ -67,6 +67,7 @@ test a new one before closing it.**
| 22000 | tcp+udp | world (Syncthing) |
| 3306 | tcp | world, rate limited (MariaDB for the bot dev; restrict via `db_allow` in /etc/nftables.conf) |
| 2222 | tcp | world (Gitea ssh) |
| 8189 | tcp+udp | world (WebRTC media for the live portal) |
Everything else is dropped and a trickle is logged (`journalctl -k | grep vg-drop`). Authentik (9000),
Jellyfin (8096), copyparty, the Syncthing UI and dumpd bind to 127.0.0.1 only and are reached via Caddy
@@ -185,6 +186,26 @@ already existed run `./createGiteaDb.sh` once.
Runner caveat: it holds the Docker socket, so anyone who can push a workflow can run as root on the box.
Keep repo write access to trusted people.
## 5f. Live portal (`live.HOST`)
React + dockview page (`live/`) behind Authentik forward-auth: every active stream is a dockable window,
audio follows the focused window, per-window mute + volume. WebRTC (WHEP) with HLS fallback, served by
MediaMTX (HLS 8888, WebRTC 8889, API 9997 on loopback; WebRTC media on **8189** udp/tcp, firewall opened).
MediaMTX runs the `-ffmpeg` image so the optional "is live" Discord hook (`LIVE_WEBHOOK_URL` in `.env`,
asked by `configure.py --full`) can post when a stream starts.
1. CNAME `live` → main host; `./configure.py --full` asks the hostname (also `--hosts … LIVE`).
2. Authentik: Proxy Provider (forward auth, single application, external host `https://live.HOST`) →
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.
## 6. Editors
Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want.

View File

@@ -17,6 +17,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Daily 04:10 ET | Recordings roll between weekday → week → month → year seasons as the calendar moves; no files change | Jellyfin plugin |
| Daily 05:00 ET | Dumped nights older than 3 days deleted for good | trash purge (systemd timer) |
| On boot | Everything restarts itself after the media mount is up; interrupted recordings stay playable | systemd + Docker |
| Continuous | Active streams are watchable live (WebRTC, HLS fallback) by Discord-server members; optional "is live" Discord post when a stream starts | MediaMTX + live portal (**built, not deployed**) |
| **Not yet running** | Pull of files older than 15 days to the home HDD | `home/rclone-archive.sh`, needs the home box |
| **Not yet running** | Nightly backup of the Authentik database (Postgres) | planned |
@@ -29,6 +30,8 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Editor | Browse by streamer/title, scrub in browser, download originals, upload finals | copyparty `https://ovh1…/` | **local account** (to move to Discord) |
| 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 |
@@ -56,10 +59,12 @@ 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 + 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
1. **Deploy the `.live` recording change** (mediamtx.yml + sweep) and **plugin 0.1.2**; refresh the one stream Jellyfin indexed while it was still recording.
1b. **Live portal**: CNAME `live`, `./configure.py --full` (live hostname + optional webhook), Authentik proxy provider for `https://live.HOST`, `./deployLive.sh`.
2. **Register the runner** (`./registerGiteaRunner.sh`).
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty.
4. **copyparty behind Authentik** (group permissions, WebDAV via app passwords, then drop local accounts). The one change that can lock editors out; keep a local admin until a Discord editor has used the new path.

View File

@@ -31,11 +31,13 @@ GIT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("git",""))
GIT=$(ask "Gitea hostname" "${GIT_CUR:-git.$BASE}")
BOT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("bot",""))')
BOT=$(ask "Bot web UI hostname" "${BOT_CUR:-bot.$BASE}")
LIVE_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("live",""))')
LIVE=$(ask "Live portal hostname" "${LIVE_CUR:-live.$BASE}")
# ---- DNS sanity ----------------------------------------------------------------------------------
say "DNS check"
MAIN_IP=$(ip_of "$MAIN"); ok=1
for h in "$AUTH" "$WATCH" "$GIT" "$BOT"; do
for h in "$AUTH" "$WATCH" "$GIT" "$BOT" "$LIVE"; do
ip=$(ip_of "$h")
if [[ -z $ip ]]; then echo " $h: does not resolve yet"; ok=0
elif [[ $ip != "$MAIN_IP" ]]; then echo " $h -> $ip (main is $MAIN_IP) — points somewhere else"; ok=0
@@ -48,7 +50,7 @@ fi
# ---- write configs, .env, start containers -----------------------------------------------------
say "Applying (configure.py --hosts)"
VG_DRY=$DRY python3 configure.py --hosts "$AUTH" "$WATCH" "$GIT" "$BOT" | sed -n '/== writing/,$p' | grep -vE '^ (techy|diceofire|[a-z0-9_-]+) password' || true
VG_DRY=$DRY python3 configure.py --hosts "$AUTH" "$WATCH" "$GIT" "$BOT" "$LIVE" | sed -n '/== writing/,$p' | grep -vE '^ (techy|diceofire|[a-z0-9_-]+) password' || true
[[ $DRY == 1 ]] && { echo "(dry run: skipping container wait)"; exit 0; }
# ---- wait ---------------------------------------------------------------------------------------
@@ -148,6 +150,12 @@ PHASE 3 — Jellyfin (direct play only on this CPU)
Custom CSS: paste jellyfin/branding.css
-> Save; reload https://$WATCH/
LIVE PORTAL (https://$LIVE/) — same Authentik forward-auth gate as the bot host:
Authentik -> Applications -> Create with provider -> Proxy Provider (forward auth, single application),
External host https://$LIVE -> add to the embedded outpost -> group policy.
Deploy the page: on the dev box `cd live && npm run build`, then ./deployLive.sh (scp dist -> /srv/live).
WebRTC viewers need UDP/TCP 8189 open (firewall rule included).
BOT WEB UI (https://$BOT/) — Authentik forward-auth gate, create once:
Authentik -> Applications -> Create with provider -> Proxy Provider
mode "Forward auth (single application)", External host https://$BOT

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

@@ -4,7 +4,7 @@
./configure.py --full first-time setup: asks every question (current values as defaults)
./configure.py manage: skips what is already set; add/remove/reset streamers & editors
./configure.py --show print current streamers, editors, domain and the OBS settings
./configure.py --hosts AUTH WATCH [GIT [BOT]] set the Authentik/Jellyfin/Gitea/bot hostnames and apply (non-interactive;
./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE]]] set the Authentik/Jellyfin/Gitea/bot/live hostnames and apply (non-interactive;
used by addCommunityMediaServer.sh)
Reads : Caddyfile (hostnames), mediamtx.yml (streamers), copyparty.conf (editors + admin), .env
@@ -134,8 +134,32 @@ AUTHENTIK_FORWARD_AUTH = """ # Authentik forward-auth (embedded outpost). Needs
}
"""
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host=""):
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
extra = ""
if live_host:
extra += f"""
# vanguard:live
{live_host} {{
encode zstd gzip
{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/* {{
reverse_proxy 127.0.0.1:8889
}}
handle_path /hls/* {{
reverse_proxy 127.0.0.1:8888
}}
handle {{
root * /srv/live
try_files {{path}} /index.html
file_server
}}
}}
"""
if bot_host:
extra += f"""
# vanguard:bot
@@ -200,8 +224,12 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host=""):
}}
""" + extra)
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host=""):
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", main_host="", live_webhook=None):
env = read_env()
env["LIVE_HOST"] = live_host or env.get("LIVE_HOST", "live.example.com")
if main_host: env["MAIN_HOST"] = main_host
if live_webhook is not None: env["LIVE_WEBHOOK_URL"] = live_webhook
env.setdefault("LIVE_WEBHOOK_URL", "")
env["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com")
env["BOT_HOST"] = bot_host or env.get("BOT_HOST", "bot.example.com")
env.setdefault("GITEA_DB_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32])
@@ -233,12 +261,23 @@ def write_mediamtx(streamers):
logLevel: info
rtsp: no
webrtc: no
hls: no
api: no
metrics: no
pprof: no
# live portal (served to the browser only via Caddy + Authentik on live.HOST; these ports are published on 127.0.0.1)
api: yes
apiAddress: :9997
hls: yes
hlsAddress: :8888
hlsVariant: lowLatency
hlsAlwaysRemux: no
webrtc: yes
webrtcAddress: :8889
webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189
webrtcIPsFromInterfaces: no
# webrtcAdditionalHosts is set via env MTX_WEBRTCADDITIONALHOSTS (= the public hostname) in docker-compose.yml
rtmp: yes
rtmpAddress: :1935
@@ -251,6 +290,11 @@ srtAddress: :8890
# SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass>
authMethod: internal
authInternalUsers:
- user: any
permissions:
- action: read
- action: playback
- action: api
{users}
# ---- recording --------------------------------------------------------
pathDefaults:
@@ -260,6 +304,13 @@ pathDefaults:
recordPartDuration: 1s
recordSegmentDuration: 12h # one file per stream unless it runs >12h
recordDeleteAfter: 0s # never auto-delete; the archive job handles it
# optional Discord "is live" notice (LIVE_WEBHOOK_URL set in .env). busybox wget in the -ffmpeg image.
runOnReady: >
sh -c '[ -n "$LIVE_WEBHOOK_URL" ] || exit 0;
u="${{MTX_PATH%%/*}}"; t="${{MTX_PATH#*/}}";
wget -q -O /dev/null --header="Content-Type: application/json"
--post-data="{{\\"content\\":\\"🔴 **$u** is live: $t — https://$LIVE_HOST/\\"}}" "$LIVE_WEBHOOK_URL"'
runOnReadyRestart: no
paths:
# any name under a streamer's prefix; inherits pathDefaults
@@ -314,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"
@@ -326,10 +381,11 @@ def run(cmd, **kw):
if DRY: return 0
return subprocess.call(cmd, **kw)
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host=""):
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); write_mediamtx(streamers); write_copyparty(admin_pw, editors); write_dump_users(admin_pw, streamers)
write_env(watch_host, discord_token, discord_guild, git_host, bot_host)
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")
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache",
"/srv/appdata/authentik/media", "/srv/appdata/authentik/templates", "/srv/appdata/authentik/certs"])
@@ -343,14 +399,16 @@ 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"]
run(dc + ["up", "-d", "--build"])
run(dc + ["restart", "mediamtx", "copyparty", "caddy"])
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host=""):
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
print(f"\n== domain : {domain or '(not set)'}")
print(f"== live : https://{live_host}/ (behind Authentik forward-auth; deploy live/dist -> /srv/live; WebRTC needs 8189 open)" if live_host else "== live : (not set — run --full)")
print(f"== bot web : https://{bot_host}/ (behind Authentik forward-auth; create the Proxy Provider for it)" if bot_host else "== bot web : (not set — run --full)")
print(f"== gitea : https://{git_host}/ ssh port 2222 (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)")
print(f"== authentik: https://{auth_host}/ (first run: https://{auth_host}/if/flow/initial-setup/)" if auth_host else "== authentik: (not set — run --full)")
@@ -393,7 +451,7 @@ def manage_users(kind, users):
else:
users[n] = ask_pw(n, users[n])
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host=""):
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
print("== FULL SETUP (enter keeps the shown default)\n")
while True:
domain = ask("public hostname (DNS A record must point here)", domain)
@@ -403,8 +461,10 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
watch_host = ask("Jellyfin hostname (CNAME to the above)", watch_host or default_sub(domain, "watch"))
git_host = ask("Gitea hostname (CNAME to the above)", git_host or default_sub(domain, "git"))
bot_host = ask("Bot web UI hostname (CNAME to the above)", bot_host or default_sub(domain, "bot"))
live_host = ask("Live portal hostname (CNAME to the above)", live_host or default_sub(domain, "live"))
print("\n-- Discord bot (optional; the Bot added to the Authentik Discord application). Enter keeps the current value.")
discord_token, discord_guild = ask_discord()
live_webhook = ask(" Discord webhook URL for 'is live' notices (optional)", read_env().get("LIVE_WEBHOOK_URL", ""))
print("\n-- admin (copyparty admin + may dump anyone's night)")
admin_pw = ask_pw("admin", admin_pw or None)
print("\n-- streamers (OBS publishers; each gets streams/<name> and clips/<name>)")
@@ -427,7 +487,7 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
n = ask_name("editor", editors)
if not n: break
editors[n] = ask_pw(n)
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook
def ask_discord():
env = read_env()
@@ -455,23 +515,24 @@ def normal(domain, admin_pw, streamers, editors):
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else ""
hosts = read_hosts()
domain, auth_host, watch_host, git_host, bot_host = hosts.get("main", ""), hosts.get("auth", ""), hosts.get("watch", ""), hosts.get("git", ""), hosts.get("bot", "")
domain, auth_host, watch_host, git_host, bot_host, live_host = hosts.get("main", ""), hosts.get("auth", ""), hosts.get("watch", ""), hosts.get("git", ""), hosts.get("bot", ""), hosts.get("live", "")
streamers, accounts = read_streamers(), read_accounts()
admin_pw = accounts.pop("admin", "")
editors = accounts
if mode == "--show":
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host); return
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
if mode == "--hosts":
if len(sys.argv) not in (4, 5, 6): print("usage: configure.py --hosts AUTH_HOST WATCH_HOST [GIT_HOST [BOT_HOST]]"); sys.exit(2)
if len(sys.argv) not in (4, 5, 6, 7): print("usage: configure.py --hosts AUTH_HOST WATCH_HOST [GIT_HOST [BOT_HOST [LIVE_HOST]]]"); sys.exit(2)
if not domain or not admin_pw: print("domain/admin not set yet — run ./configure.py --full first"); sys.exit(1)
auth_host, watch_host = sys.argv[2].strip(), sys.argv[3].strip()
if len(sys.argv) >= 5: git_host = sys.argv[4].strip()
if len(sys.argv) == 6: bot_host = sys.argv[5].strip()
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host); return
discord_token = discord_guild = None
if len(sys.argv) >= 6: bot_host = sys.argv[5].strip()
if len(sys.argv) == 7: live_host = sys.argv[6].strip()
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
discord_token = discord_guild = live_webhook = None
if mode == "--full":
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host)
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host)
if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return
elif mode == "":
r = normal(domain, admin_pw, streamers, editors)
@@ -479,8 +540,8 @@ def main():
discord_token, discord_guild = r
else:
print(__doc__); sys.exit(2)
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host)
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host)
if __name__ == "__main__":
try: main()

11
deployLive.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Build the live portal here and push the static files to /srv/live on the server. Caddy serves them as-is.
set -euo pipefail
cd "$(dirname "$0")/live"
HOST="${1:-debian@ovh1.dumpstervigilantes.rip}"
npm install --no-audit --no-fund --loglevel=error
npm run build
ssh "$HOST" 'sudo install -d -o "$(id -u)" -g "$(id -g)" /srv/live && rm -rf /srv/live/*'
scp -r dist/. "$HOST:/srv/live/"
ssh "$HOST" 'ls -la /srv/live | head -5'
echo "deployed -> https://$(ssh "$HOST" 'cd ~/vanguard-stream && grep ^LIVE_HOST= .env | cut -d= -f2')/"

View File

@@ -15,7 +15,7 @@ systemctl enable nftables >/dev/null 2>&1 || true # loads /etc/nftables.conf a
echo "== active ruleset (vanguard table)"
nft list table inet vanguard | sed 's/^/ /'
echo
echo "== listening on public interfaces (should be 22 80 443 1935 2222 3306 8890 22000 only)"
echo "== listening on public interfaces (should be 22 80 443 1935 2222 3306 8189 8890 22000 only)"
ss -tulpnH | awk '$5 !~ /^(127\.|\[::1\])/ {print " "$1, $5}' | sort -u
echo
echo "Now open a SECOND terminal and confirm: ssh debian@$(hostname -f 2>/dev/null || hostname)"

View File

@@ -46,6 +46,8 @@ table inet vanguard {
udp dport 8890 accept # SRT ingest
tcp dport 22000 accept # Syncthing
tcp dport 2222 accept # Gitea ssh (git clone/push)
udp dport 8189 accept # WebRTC media for the live portal
tcp dport 8189 accept # WebRTC ICE-TCP fallback
udp dport 22000 accept # Syncthing QUIC
# mariadb (host network): allowlist first, then anywhere with a brute-force limit

2
live/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

27
live/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Vanguard Live — the live-stream portal
React + dockview page at `https://live.<domain>/` (behind Authentik): every active stream opens as a
dockable window; audio follows the focused window; per-window mute button and volume slider.
Playback is WebRTC (WHEP) with HLS fallback, straight from MediaMTX via Caddy.
npm install
npm run build # -> dist/ (static; Caddy serves it)
npm run dev # local dev; VITE_MTX=http://<server> to proxy to a MediaMTX
Deploy: build here, `scp -r live/dist debian@HOST:~/vanguard-stream/live/`, Caddy picks it up (no restart).
Audio rules: all windows start muted; the focused window is unmuted at its slider volume. Clicking the
speaker on an unfocused window pins its audio on (yellow border) until you click it again. "Mute all"
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`).

13
live/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Vanguard Live</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1890
live/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
live/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "vanguard-live",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"dockview-react": "^8.8.3",
"hls.js": "^1.6.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"vite": "^6.0.0"
}
}

75
live/src/App.jsx Normal file
View File

@@ -0,0 +1,75 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
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
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
useEffect(() => {
let alive = true
const tick = async () => {
try {
const list = await fetchStreams(); if (!alive) return
setErr(null); setStreams(list)
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) {
if (!have.has(s.id)) {
const last = api.panels[api.panels.length - 1]
api.addPanel({
id: s.id, component: 'player', title: `${s.user} · ${s.title}`,
params: s,
position: last ? { referencePanel: last.id, direction: api.panels.length % 2 ? 'below' : 'right' } : undefined,
})
} else {
api.getPanel(s.id)?.api.updateParameters(s)
}
}
for (const p of [...api.panels]) if (!want.has(p.id)) { api.removePanel(p); audio.forget(p.id) }
} catch (e) { if (alive) setErr(e.message) }
}
tick(); const t = setInterval(tick, POLL_MS)
return () => { alive = false; clearInterval(t) }
}, [])
const onReady = useCallback(e => {
apiRef.current = e.api
e.api.onDidActivePanelChange(p => audio.focus(p?.id ?? null))
}, [])
return (
<div className="app">
<div className="topbar">
<h1>Vanguard Live</h1>
<span className="count">{streams.length ? `${streams.length} live` : 'nobody is live'}{err ? ` · ${err}` : ''}</span>
<span className="spacer" />
<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>
{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>
)
}

90
live/src/Player.jsx Normal file
View File

@@ -0,0 +1,90 @@
import React, { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { whepUrl, hlsUrl } from './api.js'
import { audio, effectiveMuted } from './useAudio.js'
// WebRTC (WHEP) first, HLS fallback. Mute/volume come from the shared audio store.
export default function Player({ params, api }) {
const { id, user, title } = params
const videoRef = useRef(null)
const [status, setStatus] = useState('connecting…')
const [viewers, setViewers] = useState(params.viewers ?? 0)
const state = audio.use()
const muted = effectiveMuted(state, id)
const volume = state.streams[id]?.volume ?? 0.8
const override = state.streams[id]?.override
const focused = state.focused === id
useEffect(() => { audio.ensure(id) }, [id])
useEffect(() => { const d = api.onDidActiveChange?.(() => {}) ; return () => d?.dispose?.() }, [api])
useEffect(() => { const t = setInterval(() => setViewers(params.viewers ?? 0), 2000); return () => clearInterval(t) }, [params.viewers])
// apply audio state to the element
useEffect(() => {
const v = videoRef.current; if (!v) return
v.muted = muted; v.volume = volume
if (!muted && v.paused) v.play().catch(() => {})
}, [muted, volume])
// connect
useEffect(() => {
const v = videoRef.current; if (!v) return
let pc, hls, cancelled = false
const startHls = () => {
if (cancelled) return
setStatus('hls')
if (Hls.isSupported()) {
hls = new Hls({ lowLatencyMode: true, liveSyncDurationCount: 2, enableWorker: true })
hls.loadSource(hlsUrl(id)); hls.attachMedia(v)
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}))
hls.on(Hls.Events.ERROR, (_, d) => { if (d.fatal) setStatus('hls error: ' + d.details) })
} else if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = hlsUrl(id); v.play().catch(() => {})
} else setStatus('no playback method')
}
const startWhep = async () => {
try {
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
pc.ontrack = e => { if (v.srcObject !== e.streams[0]) { v.srcObject = e.streams[0]; v.play().catch(() => {}) } }
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') setStatus('')
if (['failed', 'disconnected', 'closed'].includes(pc.connectionState) && !cancelled) { pc.close(); startHls() }
}
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
await new Promise(res => { if (pc.iceGatheringState === 'complete') return res(); const t = setTimeout(res, 1500); pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res() } } })
const r = await fetch(whepUrl(id), { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
if (!r.ok) throw new Error('whep ' + r.status)
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
setStatus('webrtc')
} catch (e) {
if (cancelled) return
try { pc?.close() } catch {}
startHls()
}
}
startWhep()
return () => { cancelled = true; try { pc?.close() } catch {}; try { hls?.destroy() } catch {}; v.srcObject = null; v.removeAttribute('src') }
}, [id])
const icon = muted ? '🔇' : volume === 0 ? '🔈' : volume < 0.5 ? '🔉' : '🔊'
return (
<div className={'player' + (focused ? ' focused' : '')} onMouseDown={() => api.setActive?.()}>
<video ref={videoRef} autoPlay playsInline muted />
{status && <div className="status">{status}</div>}
<div className="bar">
<span className="live">LIVE</span>
<span className="who">{user}</span>
<span className="title" title={title}>{title}</span>
<span className="viewers">👁 {viewers}</span>
<button className={'mute' + (!muted ? ' on' : '') + (override === 'unmuted' && !focused ? ' pinned' : '')}
title={override ? (override === 'unmuted' ? 'audio pinned on (click to mute)' : 'muted (click to unmute)') : (focused ? 'focused: audio on (click to mute)' : 'muted: click to pin audio on')}
onMouseDown={e => e.stopPropagation()} onClick={() => audio.toggleMute(id)}>{icon}</button>
<input type="range" min="0" max="1" step="0.02" value={volume} onMouseDown={e => e.stopPropagation()}
onChange={e => audio.setVolume(id, parseFloat(e.target.value))} />
</div>
</div>
)
}

23
live/src/api.js Normal file
View File

@@ -0,0 +1,23 @@
// MediaMTX control API, proxied by Caddy at /api (-> 127.0.0.1:9997). Returns active streams.
export async function fetchStreams() {
const r = await fetch('/api/v3/paths/list?itemsPerPage=200', { credentials: 'same-origin' })
if (!r.ok) throw new Error(`api ${r.status}`)
const data = await r.json()
return (data.items || [])
.filter(p => p.ready && (p.tracks || []).length > 0)
.map(p => {
const [user, ...rest] = p.name.split('/')
return {
id: p.name, // e.g. techy/VanguardTraining
user,
title: rest.join('/') || p.name,
viewers: (p.readers || []).length,
since: p.readyTime,
tracks: p.tracks || [],
}
})
.sort((a, b) => (a.since || '').localeCompare(b.since || ''))
}
export const whepUrl = id => `/webrtc/${id}/whep`
export const hlsUrl = id => `/hls/${id}/index.m3u8`

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
}

7
live/src/main.jsx Normal file
View File

@@ -0,0 +1,7 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import 'dockview/dist/styles/dockview.css'
import './styles.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(<App />)

55
live/src/styles.css Normal file
View File

@@ -0,0 +1,55 @@
:root { --bg:#141414; --panel:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --live:#e74c3c; }
html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg); font:14px/1.4 system-ui, sans-serif; }
.app { display:flex; flex-direction:column; height:100%; }
.topbar { display:flex; align-items:center; gap:14px; padding:8px 14px; background:#101010; border-bottom:1px solid #2a2a2a; }
.topbar h1 { font-size:15px; margin:0; font-weight:700; letter-spacing:.3px; }
.topbar .count { color:var(--mut); font-size:13px; }
.topbar .spacer { flex:1; }
.topbar button { background:#2a2a2a; color:var(--fg); border:1px solid #3a3a3a; border-radius:6px; padding:5px 10px; cursor:pointer; font-size:13px; }
.topbar button:hover { border-color:var(--acc); }
.dock { flex:1; min-height:0; }
.dockview-theme-vanguard { --dv-background-color:var(--bg); --dv-paneview-active-outline-color:var(--acc);
--dv-tabs-and-actions-container-background-color:#181818; --dv-activegroup-visiblepanel-tab-background-color:var(--panel);
--dv-activegroup-hiddenpanel-tab-background-color:#181818; --dv-inactivegroup-visiblepanel-tab-background-color:#1a1a1a;
--dv-inactivegroup-hiddenpanel-tab-background-color:#181818; --dv-tab-divider-color:#2a2a2a; --dv-activegroup-visiblepanel-tab-color:var(--fg);
--dv-activegroup-hiddenpanel-tab-color:var(--mut); --dv-inactivegroup-visiblepanel-tab-color:var(--mut); --dv-inactivegroup-hiddenpanel-tab-color:#777;
--dv-separator-border:#2a2a2a; --dv-group-view-background-color:var(--panel); --dv-drag-over-background-color:rgba(61,139,253,.2);
--dv-drag-over-border-color:var(--acc); --dv-tabs-container-scrollbar-color:#444; --dv-icon-hover-background-color:#333; }
.player { position:relative; width:100%; height:100%; background:#000; display:flex; flex-direction:column; }
.player video { flex:1; min-height:0; width:100%; height:100%; object-fit:contain; background:#000; }
.player .bar { display:flex; align-items:center; gap:10px; padding:6px 10px; background:#181818; border-top:1px solid #2a2a2a; font-size:13px; }
.player .live { background:var(--live); color:#fff; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; letter-spacing:.5px; }
.player .who { font-weight:600; }
.player .title { color:var(--mut); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0; }
.player .viewers { color:var(--mut); }
.player .mute { background:#2a2a2a; color:var(--fg); border:1px solid #3a3a3a; border-radius:6px; width:34px; height:28px; cursor:pointer; font-size:15px; }
.player .mute.on { border-color:var(--acc); }
.player .mute.pinned { border-color:#f1c40f; }
.player input[type=range] { width:110px; accent-color:var(--acc); }
.player .status { position:absolute; top:8px; left:8px; background:rgba(0,0,0,.6); color:var(--mut); font-size:12px; padding:3px 8px; border-radius:4px; }
.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; }

22
live/src/tinyStore.js Normal file
View File

@@ -0,0 +1,22 @@
import { useSyncExternalStore } from 'react'
// 40-line store: state object + action methods that return partial updates.
export function create(def) {
let state = {}
const listeners = new Set()
const api = {}
for (const [k, v] of Object.entries(def)) {
if (typeof v === 'function') {
api[k] = (...args) => {
const patch = v.apply(state, args)
if (patch) { state = { ...state, ...patch }; listeners.forEach(l => l()) }
}
} else {
state[k] = v
}
}
api.get = () => state
api.subscribe = l => { listeners.add(l); return () => listeners.delete(l) }
api.use = (selector = s => s) => useSyncExternalStore(api.subscribe, () => selector(state), () => selector(state))
return api
}

46
live/src/useAudio.js Normal file
View File

@@ -0,0 +1,46 @@
import { create } from './tinyStore.js'
// Per-stream audio state. Effective mute = override ?? !focused.
// override: null (follow focus) | 'unmuted' (pinned on) | 'muted' (pinned off)
export const audio = create({
focused: null,
streams: {}, // id -> { volume: 0..1, override }
focus(id) {
const s = { ...this.streams }
if (id && !s[id]) s[id] = { volume: 0.8, override: null }
if (id && s[id]) s[id] = { ...s[id], override: null } // focusing always gives you sound
return { focused: id, streams: s }
},
ensure(id) {
if (this.streams[id]) return null
return { streams: { ...this.streams, [id]: { volume: 0.8, override: null } } }
},
forget(id) {
const s = { ...this.streams }; delete s[id]
return { streams: s, focused: this.focused === id ? null : this.focused }
},
setVolume(id, volume) {
const cur = this.streams[id] || { volume: 0.8, override: null }
return { streams: { ...this.streams, [id]: { ...cur, volume } } }
},
toggleMute(id) {
const cur = this.streams[id] || { volume: 0.8, override: null }
const effectiveMuted = cur.override ? cur.override === 'muted' : this.focused !== id
return { streams: { ...this.streams, [id]: { ...cur, override: effectiveMuted ? 'unmuted' : 'muted' } } }
},
muteAll() {
const s = {}; for (const k in this.streams) s[k] = { ...this.streams[k], override: 'muted' }
return { streams: s }
},
followFocus() {
const s = {}; for (const k in this.streams) s[k] = { ...this.streams[k], override: null }
return { streams: s }
},
})
export function effectiveMuted(state, id) {
const cur = state.streams[id]
if (!cur) return true
if (cur.override) return cur.override === 'muted'
return state.focused !== id
}

17
live/vite.config.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Dev: `npm run dev` proxies the three MediaMTX endpoints to a running server (set VITE_MTX=https://live.example.com
// to hit production through Caddy, cookies permitting) or to a local MediaMTX.
const target = process.env.VITE_MTX || 'http://127.0.0.1'
export default defineConfig({
plugins: [react()],
build: { outDir: 'dist', emptyOutDir: true },
server: {
proxy: {
'/api': { target: `${target}:9997`, changeOrigin: true, rewrite: p => p.replace(/^\/api/, '') },
'/webrtc': { target: `${target}:8889`, changeOrigin: true, rewrite: p => p.replace(/^\/webrtc/, '') },
'/hls': { target: `${target}:8888`, changeOrigin: true, rewrite: p => p.replace(/^\/hls/, '') },
},
},
})

View File

@@ -2,12 +2,23 @@
logLevel: info
rtsp: no
webrtc: no
hls: no
api: no
metrics: no
pprof: no
# live portal (served to the browser only via Caddy + Authentik on live.HOST; these ports are published on 127.0.0.1)
api: yes
apiAddress: :9997
hls: yes
hlsAddress: :8888
hlsVariant: lowLatency
hlsAlwaysRemux: no
webrtc: yes
webrtcAddress: :8889
webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189
webrtcIPsFromInterfaces: no
# webrtcAdditionalHosts is set via env MTX_WEBRTCADDITIONALHOSTS (= the public hostname) in docker-compose.yml
rtmp: yes
rtmpAddress: :1935
@@ -20,6 +31,11 @@ srtAddress: :8890
# SRT: srt://HOST:8890?streamid=publish:techy/Title:techy:CHANGEME
authMethod: internal
authInternalUsers:
- user: any
permissions:
- action: read
- action: playback
- action: api
- user: techy
pass: CHANGEME_TECHY
permissions:
@@ -39,6 +55,13 @@ pathDefaults:
recordPartDuration: 1s
recordSegmentDuration: 12h # one file per stream unless it runs >12h
recordDeleteAfter: 0s # never auto-delete; the archive job handles it
# optional Discord "is live" notice (LIVE_WEBHOOK_URL set in .env). busybox wget in the -ffmpeg image.
runOnReady: >
sh -c '[ -n "$LIVE_WEBHOOK_URL" ] || exit 0;
u="${MTX_PATH%%/*}"; t="${MTX_PATH#*/}";
wget -q -O /dev/null --header="Content-Type: application/json"
--post-data="{\"content\":\"🔴 **$u** is live: $t — https://$LIVE_HOST/\"}" "$LIVE_WEBHOOK_URL"'
runOnReadyRestart: no
paths:
# any name under a streamer's prefix; inherits pathDefaults