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

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

View File

@@ -67,6 +67,7 @@ test a new one before closing it.**
| 22000 | tcp+udp | world (Syncthing) | | 22000 | tcp+udp | world (Syncthing) |
| 3306 | tcp | world, rate limited (MariaDB for the bot dev; restrict via `db_allow` in /etc/nftables.conf) | | 3306 | tcp | world, rate limited (MariaDB for the bot dev; restrict via `db_allow` in /etc/nftables.conf) |
| 2222 | tcp | world (Gitea ssh) | | 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), 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 Jellyfin (8096), copyparty, the Syncthing UI and dumpd bind to 127.0.0.1 only and are reached via Caddy
@@ -185,6 +186,22 @@ 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. 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. 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).
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 ## 6. Editors
Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want. 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 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) | | 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 | | 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** | 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 | | **Not yet running** | Nightly backup of the Authentik database (Postgres) | planned |
@@ -29,6 +30,7 @@ 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 | 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 | | 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 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** |
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) | | 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 | | 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 | | Admin | Add/remove streamers & editors, hostnames, bot token | `./configure.py` | ssh |
@@ -56,10 +58,12 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| act_runner | CI jobs in Docker | **not registered** | | act_runner | CI jobs in Docker | **not registered** |
| discord-bot | Python runtime, idles until `bot/app/main.py` exists | live, no code yet | | 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** | | `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** |
## Unfinished, in rough priority ## 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. 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`). 2. **Register the runner** (`./registerGiteaRunner.sh`).
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty. 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. 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}") GIT=$(ask "Gitea hostname" "${GIT_CUR:-git.$BASE}")
BOT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("bot",""))') BOT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("bot",""))')
BOT=$(ask "Bot web UI hostname" "${BOT_CUR:-bot.$BASE}") 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 ---------------------------------------------------------------------------------- # ---- DNS sanity ----------------------------------------------------------------------------------
say "DNS check" say "DNS check"
MAIN_IP=$(ip_of "$MAIN"); ok=1 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") ip=$(ip_of "$h")
if [[ -z $ip ]]; then echo " $h: does not resolve yet"; ok=0 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 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 ----------------------------------------------------- # ---- write configs, .env, start containers -----------------------------------------------------
say "Applying (configure.py --hosts)" 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; } [[ $DRY == 1 ]] && { echo "(dry run: skipping container wait)"; exit 0; }
# ---- wait --------------------------------------------------------------------------------------- # ---- wait ---------------------------------------------------------------------------------------
@@ -148,6 +150,12 @@ PHASE 3 — Jellyfin (direct play only on this CPU)
Custom CSS: paste jellyfin/branding.css Custom CSS: paste jellyfin/branding.css
-> Save; reload https://$WATCH/ -> 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: BOT WEB UI (https://$BOT/) — Authentik forward-auth gate, create once:
Authentik -> Applications -> Create with provider -> Proxy Provider Authentik -> Applications -> Create with provider -> Proxy Provider
mode "Forward auth (single application)", External host https://$BOT mode "Forward auth (single application)", External host https://$BOT

View File

@@ -4,7 +4,7 @@
./configure.py --full first-time setup: asks every question (current values as defaults) ./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 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 --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) used by addCommunityMediaServer.sh)
Reads : Caddyfile (hostnames), mediamtx.yml (streamers), copyparty.conf (editors + admin), .env 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 = "" 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: if bot_host:
extra += f""" extra += f"""
# vanguard:bot # vanguard:bot
@@ -200,8 +221,12 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host=""):
}} }}
""" + extra) """ + 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 = 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["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["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]) 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 logLevel: info
rtsp: no rtsp: no
webrtc: no
hls: no
api: no
metrics: no metrics: no
pprof: 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 rtmp: yes
rtmpAddress: :1935 rtmpAddress: :1935
@@ -251,6 +287,11 @@ srtAddress: :8890
# SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass> # SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass>
authMethod: internal authMethod: internal
authInternalUsers: authInternalUsers:
- user: any
permissions:
- action: read
- action: playback
- action: api
{users} {users}
# ---- recording -------------------------------------------------------- # ---- recording --------------------------------------------------------
pathDefaults: pathDefaults:
@@ -260,6 +301,13 @@ pathDefaults:
recordPartDuration: 1s recordPartDuration: 1s
recordSegmentDuration: 12h # one file per stream unless it runs >12h recordSegmentDuration: 12h # one file per stream unless it runs >12h
recordDeleteAfter: 0s # never auto-delete; the archive job handles it 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: paths:
# any name under a streamer's prefix; inherits pathDefaults # any name under a streamer's prefix; inherits pathDefaults
@@ -326,10 +374,11 @@ def run(cmd, **kw):
if DRY: return 0 if DRY: return 0
return subprocess.call(cmd, **kw) 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") 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_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) 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") print("== app data folders")
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache", 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"]) "/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 + ["up", "-d", "--build"])
run(dc + ["restart", "mediamtx", "copyparty", "caddy"]) 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"\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"== 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"== 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)") 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: else:
users[n] = ask_pw(n, users[n]) 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") print("== FULL SETUP (enter keeps the shown default)\n")
while True: while True:
domain = ask("public hostname (DNS A record must point here)", domain) 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")) 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")) 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")) 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.") print("\n-- Discord bot (optional; the Bot added to the Authentik Discord application). Enter keeps the current value.")
discord_token, discord_guild = ask_discord() 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)") print("\n-- admin (copyparty admin + may dump anyone's night)")
admin_pw = ask_pw("admin", admin_pw or None) admin_pw = ask_pw("admin", admin_pw or None)
print("\n-- streamers (OBS publishers; each gets streams/<name> and clips/<name>)") 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) n = ask_name("editor", editors)
if not n: break if not n: break
editors[n] = ask_pw(n) 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(): def ask_discord():
env = read_env() env = read_env()
@@ -455,23 +507,24 @@ def normal(domain, admin_pw, streamers, editors):
def main(): def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "" mode = sys.argv[1] if len(sys.argv) > 1 else ""
hosts = read_hosts() 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() streamers, accounts = read_streamers(), read_accounts()
admin_pw = accounts.pop("admin", "") admin_pw = accounts.pop("admin", "")
editors = accounts editors = accounts
if mode == "--show": 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 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) 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() 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) >= 5: git_host = sys.argv[4].strip()
if len(sys.argv) == 6: bot_host = sys.argv[5].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) if len(sys.argv) == 7: live_host = sys.argv[6].strip()
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host); return apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host)
discord_token = discord_guild = None 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": 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 if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return
elif mode == "": elif mode == "":
r = normal(domain, admin_pw, streamers, editors) r = normal(domain, admin_pw, streamers, editors)
@@ -479,8 +532,8 @@ def main():
discord_token, discord_guild = r discord_token, discord_guild = r
else: else:
print(__doc__); sys.exit(2) print(__doc__); sys.exit(2)
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, 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) show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host)
if __name__ == "__main__": if __name__ == "__main__":
try: 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)" echo "== active ruleset (vanguard table)"
nft list table inet vanguard | sed 's/^/ /' nft list table inet vanguard | sed 's/^/ /'
echo 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 ss -tulpnH | awk '$5 !~ /^(127\.|\[::1\])/ {print " "$1, $5}' | sort -u
echo echo
echo "Now open a SECOND terminal and confirm: ssh debian@$(hostname -f 2>/dev/null || hostname)" 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 udp dport 8890 accept # SRT ingest
tcp dport 22000 accept # Syncthing tcp dport 22000 accept # Syncthing
tcp dport 2222 accept # Gitea ssh (git clone/push) 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 udp dport 22000 accept # Syncthing QUIC
# mariadb (host network): allowlist first, then anywhere with a brute-force limit # 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/

18
live/README.md Normal file
View File

@@ -0,0 +1,18 @@
# 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.

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"
}
}

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

@@ -0,0 +1,68 @@
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'
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 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
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>
</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>
</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`

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 />)

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

@@ -0,0 +1,32 @@
: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; }

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 logLevel: info
rtsp: no rtsp: no
webrtc: no
hls: no
api: no
metrics: no metrics: no
pprof: 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 rtmp: yes
rtmpAddress: :1935 rtmpAddress: :1935
@@ -20,6 +31,11 @@ srtAddress: :8890
# SRT: srt://HOST:8890?streamid=publish:techy/Title:techy:CHANGEME # SRT: srt://HOST:8890?streamid=publish:techy/Title:techy:CHANGEME
authMethod: internal authMethod: internal
authInternalUsers: authInternalUsers:
- user: any
permissions:
- action: read
- action: playback
- action: api
- user: techy - user: techy
pass: CHANGEME_TECHY pass: CHANGEME_TECHY
permissions: permissions:
@@ -39,6 +55,13 @@ pathDefaults:
recordPartDuration: 1s recordPartDuration: 1s
recordSegmentDuration: 12h # one file per stream unless it runs >12h recordSegmentDuration: 12h # one file per stream unless it runs >12h
recordDeleteAfter: 0s # never auto-delete; the archive job handles it 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: paths:
# any name under a streamer's prefix; inherits pathDefaults # any name under a streamer's prefix; inherits pathDefaults