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>
This commit is contained in:
2026-10-30 23:01:11 -05:00
parent 3d42271ae2
commit 463d9b2385
22 changed files with 2398 additions and 28 deletions

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,29 @@ 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_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 +221,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 +258,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 +287,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 +301,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
@@ -326,10 +374,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_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"])
@@ -349,8 +398,9 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
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 +443,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 +453,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 +479,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 +507,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 +532,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()