From 463d9b23856b0e64ca5dabe1f85e5bd2a6f81f18 Mon Sep 17 00:00:00 2001 From: Brandyn / Techy Date: Fri, 30 Oct 2026 23:01:11 -0500 Subject: [PATCH] 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 --- .env.example | 3 + README.md | 17 + STATUS.md | 4 + addCommunityMediaServer.sh | 12 +- configure.py | 97 +- deployLive.sh | 11 + firewall/firewall.sh | 2 +- firewall/nftables.conf | 2 + live/.gitignore | 2 + live/README.md | 18 + live/index.html | 13 + live/package-lock.json | 1890 ++++++++++++++++++++++++++++++++++++ live/package.json | 21 + live/src/App.jsx | 68 ++ live/src/Player.jsx | 90 ++ live/src/api.js | 23 + live/src/main.jsx | 7 + live/src/styles.css | 32 + live/src/tinyStore.js | 22 + live/src/useAudio.js | 46 + live/vite.config.js | 17 + mediamtx.yml | 29 +- 22 files changed, 2398 insertions(+), 28 deletions(-) create mode 100755 deployLive.sh create mode 100644 live/.gitignore create mode 100644 live/README.md create mode 100644 live/index.html create mode 100644 live/package-lock.json create mode 100644 live/package.json create mode 100644 live/src/App.jsx create mode 100644 live/src/Player.jsx create mode 100644 live/src/api.js create mode 100644 live/src/main.jsx create mode 100644 live/src/styles.css create mode 100644 live/src/tinyStore.js create mode 100644 live/src/useAudio.js create mode 100644 live/vite.config.js diff --git a/.env.example b/.env.example index d9914ce..2ffb483 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index b3f30c7..1c1fb77 100644 --- a/README.md +++ b/README.md @@ -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,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. 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 Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want. diff --git a/STATUS.md b/STATUS.md index 807cb11..2882297 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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,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 | 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** | | 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 +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** | | discord-bot | Python runtime, idles until `bot/app/main.py` exists | live, no code yet | | `bot.` host | bot web UI behind Authentik forward-auth | Caddy block ready; **CNAME + Authentik proxy provider pending** | +| Live portal (`live/`, `live.` host) | React + dockview multi-stream viewer; MediaMTX HLS/WebRTC/API re-enabled on loopback | **built; CNAME, Authentik proxy provider, `./deployLive.sh`, configure apply pending** | ## 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. diff --git a/addCommunityMediaServer.sh b/addCommunityMediaServer.sh index a9d8b62..47b8801 100755 --- a/addCommunityMediaServer.sh +++ b/addCommunityMediaServer.sh @@ -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 diff --git a/configure.py b/configure.py index f632a04..43e0fa8 100755 --- a/configure.py +++ b/configure.py @@ -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:/Title:: 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/ and clips/)") @@ -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() diff --git a/deployLive.sh b/deployLive.sh new file mode 100755 index 0000000..e4385a4 --- /dev/null +++ b/deployLive.sh @@ -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')/" diff --git a/firewall/firewall.sh b/firewall/firewall.sh index 81e7ef1..afe9ee2 100755 --- a/firewall/firewall.sh +++ b/firewall/firewall.sh @@ -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)" diff --git a/firewall/nftables.conf b/firewall/nftables.conf index 85b47fd..88f5950 100644 --- a/firewall/nftables.conf +++ b/firewall/nftables.conf @@ -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 diff --git a/live/.gitignore b/live/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/live/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/live/README.md b/live/README.md new file mode 100644 index 0000000..906c3c3 --- /dev/null +++ b/live/README.md @@ -0,0 +1,18 @@ +# Vanguard Live — the live-stream portal + +React + dockview page at `https://live./` (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:// 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. diff --git a/live/index.html b/live/index.html new file mode 100644 index 0000000..d58293b --- /dev/null +++ b/live/index.html @@ -0,0 +1,13 @@ + + + + + + + Vanguard Live + + +
+ + + diff --git a/live/package-lock.json b/live/package-lock.json new file mode 100644 index 0000000..77aee69 --- /dev/null +++ b/live/package-lock.json @@ -0,0 +1,1890 @@ +{ + "name": "vanguard-live", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vanguard-live", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", + "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.12", + "@babel/types": "^7.29.11", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz", + "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.12", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.12", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.2.tgz", + "integrity": "sha512-qB7Gzl1QI45zP1zXgqLKzjFmOBGQw2s2MLtdw0eB4xdFGPNE8LhmOnItFgyYJXXNSW8Go9c2ovvXrUUzw5sAMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.2.tgz", + "integrity": "sha512-8+hXNO1J+GsYCGHwyJuBlnhyozivEvIEm/P6NkJeuoVUN+60TDvYYXk38UTm5bKRTLhIpqEQYx4d9CeN6kjh0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.2.tgz", + "integrity": "sha512-/uEZY+tcWDE8DXa/rajU3Bwv6IfvQavvzjmbkn39GgQenT+UYtAxfEMdB7VKr74y8aonncm0eQa1EbiNfg+Cjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.2.tgz", + "integrity": "sha512-3tCLEgakEvb5M3/KyYdhPjoteyWYziMTeznoXfcs/31eeFu+Vgs0VNRlb6Zc+YC07/sLa84OwTDF5zHOMFk92Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.2.tgz", + "integrity": "sha512-z31ODzkdkX7OOJCssvpz++hhtkQKWKw2L0eO2HAxFP7RMSXXedzkrTjesqCUxs4XLckRShjNfxsNxjGY5DQCGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.2.tgz", + "integrity": "sha512-CTvrb0KTAQqcrpcj7rg8FgAI0+jRWnCl+En6exqXDZztCo7vspxlRUX8z9Hp/J/ja2XRnQ1JDbexkBtRyD9jSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.2.tgz", + "integrity": "sha512-SJdblZpBkbOh1e4JwMM5pJJ2PdVmD3WWbMTXvncMSRLA3DdZuwU+P93RaSChOAXN4Mu53tBoFp1ADPzi3dIVAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.2.tgz", + "integrity": "sha512-pUBEd7uI9dAu3TpbPcvi9eU2aJslsjIomormeTaQHmzWNhyqNx3mmq+ekC6blGNZu8F3uTHngTibKp7tbblF0A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.2.tgz", + "integrity": "sha512-wW6ARw2Ah0/teOBTgTeSJIpVdPKaxQmEgZ/irkUfr3F9sgimqpApCv/NbQzjm0Nfi5EFilHh1H8kz01uQLaq5Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.2.tgz", + "integrity": "sha512-xcFYMHNL42fRu2MFipvxznr+zd+DRqwL98bhNvDWbMPRcFtMYf34NAz4xVOlnEhzgpNRE5htSemcqAsAxf2eQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.2.tgz", + "integrity": "sha512-U9HrnJ9PFXqA//P/oEGaXBIJraEqzYMS9yuQOdOcPCvHZIC1FFOlnd8ocAaeDDPDpY8xOrhfxMRtr6KqKi3gyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.2.tgz", + "integrity": "sha512-2hZN8CFGfsbDw+DNtImkPRitpLNjVM2XEbi4ZQ5igLxMFD4gI/P6Zo9czGxuvQXtRyZtZyZp8Kr4ERsAipplBw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.2.tgz", + "integrity": "sha512-HZO7UfWEsReAnkdVByyQaFsNXJCtvdfun/LFb2Xd4qnhgF1ZfhikpbgqrTRtNMmB5OgNnSwFLiPobOT8oNT9iw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.2.tgz", + "integrity": "sha512-AdYIihuLkJ2TScy9BpSPiDex3zOMjkNJLsFlk9NeJ0U3WEtOx4W8z0nOkPEhDS2S4gwsRWBa6ZZexJ9e6OxyzQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.2.tgz", + "integrity": "sha512-iZE0NXcAGqLoCWeHRPkBxJe93JT4Q981z1ZqR+y7qAiIksdRlWhw8hmqHFo1B4ZnU66He31NUb52Iyv+vwMEBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.2.tgz", + "integrity": "sha512-vC7EAtJpSqSrrbg8huMfgxtwlR8fUsFCe5rStDk1WA8POky/6EEp52dxENMmqLKjSfdM3TT1WWPcZ01hiYZcdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.2.tgz", + "integrity": "sha512-DdqG6x72MHpQOlkK/cqrru9b2QMJHLkNojCB3bteB5fmkE/7XEAkReJMANp8wgef8eiEC99SvdP/BH7Ho/BpLA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.2.tgz", + "integrity": "sha512-F10mjdnRmGg9K+LGdqQVeUYFr08f3fVBOh7YNE5p1KIpTzhtKKOLqcQEsqZG6yEM2eouXaMo2AZzeDAcseVTrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.2.tgz", + "integrity": "sha512-ChzK9f3iE4N5GInBquiZtzWOqh+CYS5nlLlXGJxeG3d2T9rk8L1Hxg0s+v2Eh6vmAc/1T6IhOVkQU0ORP2eV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.2.tgz", + "integrity": "sha512-95WwzC+KH9hPHtbgwvwwW6MkKME6vMAdAQLS2diCvoi/iUiYdC0Y1RIPYVESx0iWqkN5ZaZq6qD1BflLcyTMEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.2.tgz", + "integrity": "sha512-eKbpwlolQmDKd/dbFjvqB1+2qdYQLrgxi3Mtbijp7/3yBBvO9OoHtQ3ecYIaCdW46ijSjy5/yydyrrxHI416/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.2.tgz", + "integrity": "sha512-is9lnlv6khapl+H9Hjp0gIVsUVtDxQga2nwMHpQbhDXMCMGcNmlel4/q9ihf/3FvvbL4GdGOPOGvWoei2RCggg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.2.tgz", + "integrity": "sha512-yTq+7YjsgbBcMdlk7Wq+VoJilGQtdtRc4PyTvN7vhO958atCO2nQeIjCF/YANSD/Z/fnrCr27fYsGgC6PFTndQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.2.tgz", + "integrity": "sha512-nAvZib4Xz1zx8OKhtRuyCsSmCmbfegv1AGbfpG+CowQKxCBW1pLlfYWjcr1+GI3BzRKQbBl7l9640GwWAwOF7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.2.tgz", + "integrity": "sha512-R3CVbEbNa1OXjpldD1tOLarb5rB08xjQiRMpe7PZKtT8eHCZhrV4ClSPfLLbSgRMM1RfLtmAfouBRf14f47lMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.2.tgz", + "integrity": "sha512-DeuVFilvaGtoS9TFIbTIQ0Kt4CBqP3xnjJ8SFqWWbXlFbrpmCdivEW7Rm38trRA8rZzQQ8lhs73nEetWotJpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001820", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz", + "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dockview": { + "version": "8.8.3", + "resolved": "https://registry.npmjs.org/dockview/-/dockview-8.8.3.tgz", + "integrity": "sha512-hTb5X4Z74k0+5wtOlx1gNsx/q5ve1p0JQ6s7Ovl24YhdkaR9AhfWFlq1dQYwXjXIoWm8RFW6aTZMhCTiEv94EA==", + "license": "MIT", + "dependencies": { + "dockview-core": "^8.8.3" + } + }, + "node_modules/dockview-core": { + "version": "8.8.3", + "resolved": "https://registry.npmjs.org/dockview-core/-/dockview-core-8.8.3.tgz", + "integrity": "sha512-B9v+ibiXnZ5qgFJ7VEqMcNxpG0S5Y7Yx7uuGqqSyEFSC3bRZktUoXWo3HCrFp+QTdFmA1dJD1ye/gI4yL3hU2g==", + "license": "MIT" + }, + "node_modules/dockview-react": { + "version": "8.8.3", + "resolved": "https://registry.npmjs.org/dockview-react/-/dockview-react-8.8.3.tgz", + "integrity": "sha512-pfd5Dgwbh+OeGIwrctIsRLeDb5rLTuKK9rBM2ZRKEGLri9KHPL58FYAYTzZ2T5m/Fy6ysjRgVe8hBgib2JAmmA==", + "license": "MIT", + "dependencies": { + "dockview": "^8.8.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.471", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.471.tgz", + "integrity": "sha512-Lo7jyyneHRYUQYM3WZ2rBEei9FcUIwValFPLifTbr4NTpc6oyUB+nN2rrOtbn3CE5pgz930mYxRJeD+glR7d6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hls.js": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.4.tgz", + "integrity": "sha512-3GlxGdPvT2YHvQYOt+yOzT/DEior/qJVNIylHA77kgeoBTdQQqi70GwefNEUmHQeNGyjC8U62+bruqW/Tpm+tg==", + "license": "Apache-2.0" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.62", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.62.tgz", + "integrity": "sha512-A0Eq+qVDQI97IbAD6g/HriIuv256el9N8EwF4+xqJ49pwrdRYpdCSmLk0sO4I3fnS6hpYE5v22B73Bk+WArEoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.2.tgz", + "integrity": "sha512-sFZk5BeRUQzkjmuClrJI41BowIP4yv/40d5gM9vtjxVLEt+8JG9FGlBps6cliTP2KhFiw22oqNGo0G9pVpPZlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.67.2", + "@rollup/rollup-android-arm64": "4.67.2", + "@rollup/rollup-android-x64": "4.67.2", + "@rollup/rollup-darwin-arm64": "4.67.2", + "@rollup/rollup-darwin-x64": "4.67.2", + "@rollup/rollup-freebsd-arm64": "4.67.2", + "@rollup/rollup-freebsd-x64": "4.67.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.2", + "@rollup/rollup-linux-arm-musleabihf": "4.67.2", + "@rollup/rollup-linux-arm64-gnu": "4.67.2", + "@rollup/rollup-linux-arm64-musl": "4.67.2", + "@rollup/rollup-linux-loong64-gnu": "4.67.2", + "@rollup/rollup-linux-loong64-musl": "4.67.2", + "@rollup/rollup-linux-ppc64-gnu": "4.67.2", + "@rollup/rollup-linux-ppc64-musl": "4.67.2", + "@rollup/rollup-linux-riscv64-gnu": "4.67.2", + "@rollup/rollup-linux-riscv64-musl": "4.67.2", + "@rollup/rollup-linux-s390x-gnu": "4.67.2", + "@rollup/rollup-linux-x64-gnu": "4.67.2", + "@rollup/rollup-linux-x64-musl": "4.67.2", + "@rollup/rollup-openbsd-x64": "4.67.2", + "@rollup/rollup-openharmony-arm64": "4.67.2", + "@rollup/rollup-win32-arm64-msvc": "4.67.2", + "@rollup/rollup-win32-ia32-msvc": "4.67.2", + "@rollup/rollup-win32-x64-gnu": "4.67.2", + "@rollup/rollup-win32-x64-msvc": "4.67.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/live/package.json b/live/package.json new file mode 100644 index 0000000..9f53481 --- /dev/null +++ b/live/package.json @@ -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" + } +} diff --git a/live/src/App.jsx b/live/src/App.jsx new file mode 100644 index 0000000..e69f864 --- /dev/null +++ b/live/src/App.jsx @@ -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 ( +
+
+

Vanguard Live

+ {streams.length ? `${streams.length} live` : 'nobody is live'}{err ? ` · ${err}` : ''} + + audio follows the focused window + + +
+ {streams.length === 0 && !err && ( +

Nobody is live right now

This page updates itself when a stream starts.
+ )} +
+ +
+
+ ) +} diff --git a/live/src/Player.jsx b/live/src/Player.jsx new file mode 100644 index 0000000..2e9e6ad --- /dev/null +++ b/live/src/Player.jsx @@ -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 ( +
api.setActive?.()}> +
+ ) +} diff --git a/live/src/api.js b/live/src/api.js new file mode 100644 index 0000000..278aae7 --- /dev/null +++ b/live/src/api.js @@ -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` diff --git a/live/src/main.jsx b/live/src/main.jsx new file mode 100644 index 0000000..bc1c328 --- /dev/null +++ b/live/src/main.jsx @@ -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() diff --git a/live/src/styles.css b/live/src/styles.css new file mode 100644 index 0000000..a76d0b5 --- /dev/null +++ b/live/src/styles.css @@ -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; } diff --git a/live/src/tinyStore.js b/live/src/tinyStore.js new file mode 100644 index 0000000..975bca0 --- /dev/null +++ b/live/src/tinyStore.js @@ -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 +} diff --git a/live/src/useAudio.js b/live/src/useAudio.js new file mode 100644 index 0000000..e173030 --- /dev/null +++ b/live/src/useAudio.js @@ -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 +} diff --git a/live/vite.config.js b/live/vite.config.js new file mode 100644 index 0000000..8d206ea --- /dev/null +++ b/live/vite.config.js @@ -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/, '') }, + }, + }, +}) diff --git a/mediamtx.yml b/mediamtx.yml index 3d10b5b..6b8f900 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -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