Compare commits
2 Commits
3d42271ae2
...
82df0b1583
| Author | SHA1 | Date | |
|---|---|---|---|
| 82df0b1583 | |||
| 463d9b2385 |
@@ -13,3 +13,6 @@ GIT_HOST=git.example.com
|
|||||||
GITEA_DB_PASSWORD=
|
GITEA_DB_PASSWORD=
|
||||||
GITEA_RUNNER_TOKEN=
|
GITEA_RUNNER_TOKEN=
|
||||||
BOT_HOST=bot.example.com
|
BOT_HOST=bot.example.com
|
||||||
|
LIVE_HOST=live.example.com
|
||||||
|
MAIN_HOST=media.example.com
|
||||||
|
LIVE_WEBHOOK_URL=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
bot/app/__pycache__/
|
bot/app/__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
streamers.json
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ Checklist:
|
|||||||
- [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity
|
- [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity
|
||||||
|
|
||||||
### Phase 7 — dock polish + cleanup
|
### Phase 7 — dock polish + cleanup
|
||||||
|
(partly done: dumpd `/me/stream-key` exists and the live portal's Go Live uses it; the OBS dock still holds its own copy of the key)
|
||||||
- [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer;
|
- [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer;
|
||||||
dock fills OBS settings from it — nobody types a stream password again
|
dock fills OBS settings from it — nobody types a stream password again
|
||||||
- [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik)
|
- [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik)
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -67,6 +67,7 @@ test a new one before closing it.**
|
|||||||
| 22000 | tcp+udp | world (Syncthing) |
|
| 22000 | tcp+udp | world (Syncthing) |
|
||||||
| 3306 | tcp | world, rate limited (MariaDB for the bot dev; restrict via `db_allow` in /etc/nftables.conf) |
|
| 3306 | tcp | world, rate limited (MariaDB for the bot dev; restrict via `db_allow` in /etc/nftables.conf) |
|
||||||
| 2222 | tcp | world (Gitea ssh) |
|
| 2222 | tcp | world (Gitea ssh) |
|
||||||
|
| 8189 | tcp+udp | world (WebRTC media for the live portal) |
|
||||||
|
|
||||||
Everything else is dropped and a trickle is logged (`journalctl -k | grep vg-drop`). Authentik (9000),
|
Everything else is dropped and a trickle is logged (`journalctl -k | grep vg-drop`). Authentik (9000),
|
||||||
Jellyfin (8096), copyparty, the Syncthing UI and dumpd bind to 127.0.0.1 only and are reached via Caddy
|
Jellyfin (8096), copyparty, the Syncthing UI and dumpd bind to 127.0.0.1 only and are reached via Caddy
|
||||||
@@ -185,6 +186,26 @@ already existed run `./createGiteaDb.sh` once.
|
|||||||
Runner caveat: it holds the Docker socket, so anyone who can push a workflow can run as root on the box.
|
Runner caveat: it holds the Docker socket, so anyone who can push a workflow can run as root on the box.
|
||||||
Keep repo write access to trusted people.
|
Keep repo write access to trusted people.
|
||||||
|
|
||||||
|
## 5f. Live portal (`live.HOST`)
|
||||||
|
|
||||||
|
React + dockview page (`live/`) behind Authentik forward-auth: every active stream is a dockable window,
|
||||||
|
audio follows the focused window, per-window mute + volume. WebRTC (WHEP) with HLS fallback, served by
|
||||||
|
MediaMTX (HLS 8888, WebRTC 8889, API 9997 on loopback; WebRTC media on **8189** udp/tcp, firewall opened).
|
||||||
|
MediaMTX runs the `-ffmpeg` image so the optional "is live" Discord hook (`LIVE_WEBHOOK_URL` in `.env`,
|
||||||
|
asked by `configure.py --full`) can post when a stream starts.
|
||||||
|
|
||||||
|
1. CNAME `live` → main host; `./configure.py --full` asks the hostname (also `--hosts … LIVE`).
|
||||||
|
2. Authentik: Proxy Provider (forward auth, single application, external host `https://live.HOST`) →
|
||||||
|
embedded outpost → group policy (e.g. everyone in the server, or `streamers`+`editors`+`admins`).
|
||||||
|
3. Dev box: `./deployLive.sh` builds `live/` and copies `dist/` to `/srv/live` (Caddy serves it directly).
|
||||||
|
|
||||||
|
**Go Live** button: broadcast a window from the browser (no OBS) as `<user>/HelmetCam-<date>` — key comes
|
||||||
|
from `/me/stream-key` (dumpd reads `/etc/vanguard/streamers.json`, identity from the Authentik header), so a
|
||||||
|
streamer's Authentik username must equal their MediaMTX name.
|
||||||
|
|
||||||
|
Bandwidth: no transcoding, every viewer gets the source bitrate (~15 Mbps) → ~30 concurrent viewers on
|
||||||
|
the 500 Mbps line. For a crowd, restream to Twitch from OBS.
|
||||||
|
|
||||||
## 6. Editors
|
## 6. Editors
|
||||||
|
|
||||||
Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want.
|
Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
|||||||
| Daily 04:10 ET | Recordings roll between weekday → week → month → year seasons as the calendar moves; no files change | Jellyfin plugin |
|
| Daily 04:10 ET | Recordings roll between weekday → week → month → year seasons as the calendar moves; no files change | Jellyfin plugin |
|
||||||
| Daily 05:00 ET | Dumped nights older than 3 days deleted for good | trash purge (systemd timer) |
|
| Daily 05:00 ET | Dumped nights older than 3 days deleted for good | trash purge (systemd timer) |
|
||||||
| On boot | Everything restarts itself after the media mount is up; interrupted recordings stay playable | systemd + Docker |
|
| On boot | Everything restarts itself after the media mount is up; interrupted recordings stay playable | systemd + Docker |
|
||||||
|
| Continuous | Active streams are watchable live (WebRTC, HLS fallback) by Discord-server members; optional "is live" Discord post when a stream starts | MediaMTX + live portal (**built, not deployed**) |
|
||||||
| **Not yet running** | Pull of files older than 15 days to the home HDD | `home/rclone-archive.sh`, needs the home box |
|
| **Not yet running** | Pull of files older than 15 days to the home HDD | `home/rclone-archive.sh`, needs the home box |
|
||||||
| **Not yet running** | Nightly backup of the Authentik database (Postgres) | planned |
|
| **Not yet running** | Nightly backup of the Authentik database (Postgres) | planned |
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
|||||||
| Editor | Browse by streamer/title, scrub in browser, download originals, upload finals | copyparty `https://ovh1…/` | **local account** (to move to Discord) |
|
| Editor | Browse by streamer/title, scrub in browser, download originals, upload finals | copyparty `https://ovh1…/` | **local account** (to move to Discord) |
|
||||||
| Editor | Mount the library as a drive | WebDAV, same URL | same |
|
| Editor | Mount the library as a drive | WebDAV, same URL | same |
|
||||||
| Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) |
|
| Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) |
|
||||||
|
| Anyone in the Discord server | Watch live: all active streams in dockable windows, audio follows focus, per-window mute/volume | Live portal `https://live.…/` | Discord (Authentik) — **pending deploy** |
|
||||||
|
| Streamer | **Go Live** from the browser: pick the WarDogs window (+audio), streams as `<user>/HelmetCam-<date>`; no OBS needed | Live portal → Go Live | Discord; key fetched for the logged-in user — **pending deploy** |
|
||||||
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) |
|
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) |
|
||||||
| Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists |
|
| Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists |
|
||||||
| Admin | Add/remove streamers & editors, hostnames, bot token | `./configure.py` | ssh |
|
| Admin | Add/remove streamers & editors, hostnames, bot token | `./configure.py` | ssh |
|
||||||
@@ -56,10 +59,12 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
|||||||
| act_runner | CI jobs in Docker | **not registered** |
|
| act_runner | CI jobs in Docker | **not registered** |
|
||||||
| discord-bot | Python runtime, idles until `bot/app/main.py` exists | live, no code yet |
|
| discord-bot | Python runtime, idles until `bot/app/main.py` exists | live, no code yet |
|
||||||
| `bot.` host | bot web UI behind Authentik forward-auth | Caddy block ready; **CNAME + Authentik proxy provider pending** |
|
| `bot.` host | bot web UI behind Authentik forward-auth | Caddy block ready; **CNAME + Authentik proxy provider pending** |
|
||||||
|
| Live portal (`live/`, `live.` host) | React + dockview multi-stream viewer + browser Go Live (WHIP); MediaMTX HLS/WebRTC/API re-enabled on loopback; dumpd `/me/stream-key` | **built; CNAME, Authentik proxy provider, `./deployLive.sh`, configure apply pending** |
|
||||||
|
|
||||||
## Unfinished, in rough priority
|
## Unfinished, in rough priority
|
||||||
|
|
||||||
1. **Deploy the `.live` recording change** (mediamtx.yml + sweep) and **plugin 0.1.2**; refresh the one stream Jellyfin indexed while it was still recording.
|
1. **Deploy the `.live` recording change** (mediamtx.yml + sweep) and **plugin 0.1.2**; refresh the one stream Jellyfin indexed while it was still recording.
|
||||||
|
1b. **Live portal**: CNAME `live`, `./configure.py --full` (live hostname + optional webhook), Authentik proxy provider for `https://live.HOST`, `./deployLive.sh`.
|
||||||
2. **Register the runner** (`./registerGiteaRunner.sh`).
|
2. **Register the runner** (`./registerGiteaRunner.sh`).
|
||||||
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty.
|
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty.
|
||||||
4. **copyparty behind Authentik** (group permissions, WebDAV via app passwords, then drop local accounts). The one change that can lock editors out; keep a local admin until a Discord editor has used the new path.
|
4. **copyparty behind Authentik** (group permissions, WebDAV via app passwords, then drop local accounts). The one change that can lock editors out; keep a local admin until a Discord editor has used the new path.
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ GIT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("git",""))
|
|||||||
GIT=$(ask "Gitea hostname" "${GIT_CUR:-git.$BASE}")
|
GIT=$(ask "Gitea hostname" "${GIT_CUR:-git.$BASE}")
|
||||||
BOT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("bot",""))')
|
BOT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("bot",""))')
|
||||||
BOT=$(ask "Bot web UI hostname" "${BOT_CUR:-bot.$BASE}")
|
BOT=$(ask "Bot web UI hostname" "${BOT_CUR:-bot.$BASE}")
|
||||||
|
LIVE_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("live",""))')
|
||||||
|
LIVE=$(ask "Live portal hostname" "${LIVE_CUR:-live.$BASE}")
|
||||||
|
|
||||||
# ---- DNS sanity ----------------------------------------------------------------------------------
|
# ---- DNS sanity ----------------------------------------------------------------------------------
|
||||||
say "DNS check"
|
say "DNS check"
|
||||||
MAIN_IP=$(ip_of "$MAIN"); ok=1
|
MAIN_IP=$(ip_of "$MAIN"); ok=1
|
||||||
for h in "$AUTH" "$WATCH" "$GIT" "$BOT"; do
|
for h in "$AUTH" "$WATCH" "$GIT" "$BOT" "$LIVE"; do
|
||||||
ip=$(ip_of "$h")
|
ip=$(ip_of "$h")
|
||||||
if [[ -z $ip ]]; then echo " $h: does not resolve yet"; ok=0
|
if [[ -z $ip ]]; then echo " $h: does not resolve yet"; ok=0
|
||||||
elif [[ $ip != "$MAIN_IP" ]]; then echo " $h -> $ip (main is $MAIN_IP) — points somewhere else"; ok=0
|
elif [[ $ip != "$MAIN_IP" ]]; then echo " $h -> $ip (main is $MAIN_IP) — points somewhere else"; ok=0
|
||||||
@@ -48,7 +50,7 @@ fi
|
|||||||
|
|
||||||
# ---- write configs, .env, start containers -----------------------------------------------------
|
# ---- write configs, .env, start containers -----------------------------------------------------
|
||||||
say "Applying (configure.py --hosts)"
|
say "Applying (configure.py --hosts)"
|
||||||
VG_DRY=$DRY python3 configure.py --hosts "$AUTH" "$WATCH" "$GIT" "$BOT" | sed -n '/== writing/,$p' | grep -vE '^ (techy|diceofire|[a-z0-9_-]+) password' || true
|
VG_DRY=$DRY python3 configure.py --hosts "$AUTH" "$WATCH" "$GIT" "$BOT" "$LIVE" | sed -n '/== writing/,$p' | grep -vE '^ (techy|diceofire|[a-z0-9_-]+) password' || true
|
||||||
[[ $DRY == 1 ]] && { echo "(dry run: skipping container wait)"; exit 0; }
|
[[ $DRY == 1 ]] && { echo "(dry run: skipping container wait)"; exit 0; }
|
||||||
|
|
||||||
# ---- wait ---------------------------------------------------------------------------------------
|
# ---- wait ---------------------------------------------------------------------------------------
|
||||||
@@ -148,6 +150,12 @@ PHASE 3 — Jellyfin (direct play only on this CPU)
|
|||||||
Custom CSS: paste jellyfin/branding.css
|
Custom CSS: paste jellyfin/branding.css
|
||||||
-> Save; reload https://$WATCH/
|
-> Save; reload https://$WATCH/
|
||||||
|
|
||||||
|
LIVE PORTAL (https://$LIVE/) — same Authentik forward-auth gate as the bot host:
|
||||||
|
Authentik -> Applications -> Create with provider -> Proxy Provider (forward auth, single application),
|
||||||
|
External host https://$LIVE -> add to the embedded outpost -> group policy.
|
||||||
|
Deploy the page: on the dev box `cd live && npm run build`, then ./deployLive.sh (scp dist -> /srv/live).
|
||||||
|
WebRTC viewers need UDP/TCP 8189 open (firewall rule included).
|
||||||
|
|
||||||
BOT WEB UI (https://$BOT/) — Authentik forward-auth gate, create once:
|
BOT WEB UI (https://$BOT/) — Authentik forward-auth gate, create once:
|
||||||
Authentik -> Applications -> Create with provider -> Proxy Provider
|
Authentik -> Applications -> Create with provider -> Proxy Provider
|
||||||
mode "Forward auth (single application)", External host https://$BOT
|
mode "Forward auth (single application)", External host https://$BOT
|
||||||
|
|||||||
19
bin/dumpd.py
19
bin/dumpd.py
@@ -19,6 +19,8 @@ SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
|
|||||||
TRASH = ROOT / "trash"
|
TRASH = ROOT / "trash"
|
||||||
MARK = ROOT / ".markers"
|
MARK = ROOT / ".markers"
|
||||||
USERS_FILE = Path(os.environ.get("DUMP_USERS", "/etc/vanguard/dump-users.txt"))
|
USERS_FILE = Path(os.environ.get("DUMP_USERS", "/etc/vanguard/dump-users.txt"))
|
||||||
|
STREAMERS_FILE = Path(os.environ.get("STREAMERS_FILE", "/etc/vanguard/streamers.json")) # {"techy": "pass", ...} written by configure.py
|
||||||
|
AUTH_USER_HEADER = os.environ.get("AUTH_USER_HEADER", "X-Authentik-Username")
|
||||||
LISTEN = ("127.0.0.1", 8080)
|
LISTEN = ("127.0.0.1", 8080)
|
||||||
TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
|
TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
|
||||||
NIGHT_START = time(17, 0) # 5 pm
|
NIGHT_START = time(17, 0) # 5 pm
|
||||||
@@ -92,6 +94,12 @@ def do_move(files, night):
|
|||||||
d.rmdir()
|
d.rmdir()
|
||||||
return moved, size
|
return moved, size
|
||||||
|
|
||||||
|
def load_streamers():
|
||||||
|
try:
|
||||||
|
return json.loads(STREAMERS_FILE.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
class H(BaseHTTPRequestHandler):
|
class H(BaseHTTPRequestHandler):
|
||||||
server_version = "dumpd/1"
|
server_version = "dumpd/1"
|
||||||
def _json(self, code, obj):
|
def _json(self, code, obj):
|
||||||
@@ -129,6 +137,17 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return req_user or "all"
|
return req_user or "all"
|
||||||
return auth_user
|
return auth_user
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
|
if self.path.startswith("/me/stream-key"):
|
||||||
|
# Only reachable through Caddy behind Authentik forward-auth, which injects the username header.
|
||||||
|
# dumpd binds to loopback, so a request carrying this header is one Caddy already authenticated.
|
||||||
|
who = (self.headers.get(AUTH_USER_HEADER) or "").strip().lower()
|
||||||
|
if not who:
|
||||||
|
return self._json(401, {"error": "not authenticated"})
|
||||||
|
streamers = load_streamers()
|
||||||
|
pw = streamers.get(who)
|
||||||
|
if not pw:
|
||||||
|
return self._json(403, {"error": f"no stream key for '{who}' — ask the admin to add you as a streamer with that exact name"})
|
||||||
|
return self._json(200, {"user": who, "pass": pw})
|
||||||
if not self.path.startswith("/dump"):
|
if not self.path.startswith("/dump"):
|
||||||
return self._json(404, {"error": "not found"})
|
return self._json(404, {"error": "not found"})
|
||||||
u = check_auth(self.headers.get("Authorization"))
|
u = check_auth(self.headers.get("Authorization"))
|
||||||
|
|||||||
105
configure.py
105
configure.py
@@ -4,7 +4,7 @@
|
|||||||
./configure.py --full first-time setup: asks every question (current values as defaults)
|
./configure.py --full first-time setup: asks every question (current values as defaults)
|
||||||
./configure.py manage: skips what is already set; add/remove/reset streamers & editors
|
./configure.py manage: skips what is already set; add/remove/reset streamers & editors
|
||||||
./configure.py --show print current streamers, editors, domain and the OBS settings
|
./configure.py --show print current streamers, editors, domain and the OBS settings
|
||||||
./configure.py --hosts AUTH WATCH [GIT [BOT]] set the Authentik/Jellyfin/Gitea/bot hostnames and apply (non-interactive;
|
./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE]]] set the Authentik/Jellyfin/Gitea/bot/live hostnames and apply (non-interactive;
|
||||||
used by addCommunityMediaServer.sh)
|
used by addCommunityMediaServer.sh)
|
||||||
|
|
||||||
Reads : Caddyfile (hostnames), mediamtx.yml (streamers), copyparty.conf (editors + admin), .env
|
Reads : Caddyfile (hostnames), mediamtx.yml (streamers), copyparty.conf (editors + admin), .env
|
||||||
@@ -134,8 +134,32 @@ AUTHENTIK_FORWARD_AUTH = """ # Authentik forward-auth (embedded outpost). Needs
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host=""):
|
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
|
||||||
extra = ""
|
extra = ""
|
||||||
|
if live_host:
|
||||||
|
extra += f"""
|
||||||
|
# vanguard:live
|
||||||
|
{live_host} {{
|
||||||
|
encode zstd gzip
|
||||||
|
{AUTHENTIK_FORWARD_AUTH} handle /me/* {{
|
||||||
|
reverse_proxy 127.0.0.1:8080 # dumpd: /me/stream-key for the logged-in streamer (Go Live)
|
||||||
|
}}
|
||||||
|
handle_path /api/* {{
|
||||||
|
reverse_proxy 127.0.0.1:9997
|
||||||
|
}}
|
||||||
|
handle_path /webrtc/* {{
|
||||||
|
reverse_proxy 127.0.0.1:8889
|
||||||
|
}}
|
||||||
|
handle_path /hls/* {{
|
||||||
|
reverse_proxy 127.0.0.1:8888
|
||||||
|
}}
|
||||||
|
handle {{
|
||||||
|
root * /srv/live
|
||||||
|
try_files {{path}} /index.html
|
||||||
|
file_server
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
if bot_host:
|
if bot_host:
|
||||||
extra += f"""
|
extra += f"""
|
||||||
# vanguard:bot
|
# vanguard:bot
|
||||||
@@ -200,8 +224,12 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host=""):
|
|||||||
}}
|
}}
|
||||||
""" + extra)
|
""" + extra)
|
||||||
|
|
||||||
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host=""):
|
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", main_host="", live_webhook=None):
|
||||||
env = read_env()
|
env = read_env()
|
||||||
|
env["LIVE_HOST"] = live_host or env.get("LIVE_HOST", "live.example.com")
|
||||||
|
if main_host: env["MAIN_HOST"] = main_host
|
||||||
|
if live_webhook is not None: env["LIVE_WEBHOOK_URL"] = live_webhook
|
||||||
|
env.setdefault("LIVE_WEBHOOK_URL", "")
|
||||||
env["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com")
|
env["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com")
|
||||||
env["BOT_HOST"] = bot_host or env.get("BOT_HOST", "bot.example.com")
|
env["BOT_HOST"] = bot_host or env.get("BOT_HOST", "bot.example.com")
|
||||||
env.setdefault("GITEA_DB_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32])
|
env.setdefault("GITEA_DB_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32])
|
||||||
@@ -233,12 +261,23 @@ def write_mediamtx(streamers):
|
|||||||
logLevel: info
|
logLevel: info
|
||||||
|
|
||||||
rtsp: no
|
rtsp: no
|
||||||
webrtc: no
|
|
||||||
hls: no
|
|
||||||
api: no
|
|
||||||
metrics: no
|
metrics: no
|
||||||
pprof: no
|
pprof: no
|
||||||
|
|
||||||
|
# live portal (served to the browser only via Caddy + Authentik on live.HOST; these ports are published on 127.0.0.1)
|
||||||
|
api: yes
|
||||||
|
apiAddress: :9997
|
||||||
|
hls: yes
|
||||||
|
hlsAddress: :8888
|
||||||
|
hlsVariant: lowLatency
|
||||||
|
hlsAlwaysRemux: no
|
||||||
|
webrtc: yes
|
||||||
|
webrtcAddress: :8889
|
||||||
|
webrtcLocalUDPAddress: :8189
|
||||||
|
webrtcLocalTCPAddress: :8189
|
||||||
|
webrtcIPsFromInterfaces: no
|
||||||
|
# webrtcAdditionalHosts is set via env MTX_WEBRTCADDITIONALHOSTS (= the public hostname) in docker-compose.yml
|
||||||
|
|
||||||
rtmp: yes
|
rtmp: yes
|
||||||
rtmpAddress: :1935
|
rtmpAddress: :1935
|
||||||
|
|
||||||
@@ -251,6 +290,11 @@ srtAddress: :8890
|
|||||||
# SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass>
|
# SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass>
|
||||||
authMethod: internal
|
authMethod: internal
|
||||||
authInternalUsers:
|
authInternalUsers:
|
||||||
|
- user: any
|
||||||
|
permissions:
|
||||||
|
- action: read
|
||||||
|
- action: playback
|
||||||
|
- action: api
|
||||||
{users}
|
{users}
|
||||||
# ---- recording --------------------------------------------------------
|
# ---- recording --------------------------------------------------------
|
||||||
pathDefaults:
|
pathDefaults:
|
||||||
@@ -260,6 +304,13 @@ pathDefaults:
|
|||||||
recordPartDuration: 1s
|
recordPartDuration: 1s
|
||||||
recordSegmentDuration: 12h # one file per stream unless it runs >12h
|
recordSegmentDuration: 12h # one file per stream unless it runs >12h
|
||||||
recordDeleteAfter: 0s # never auto-delete; the archive job handles it
|
recordDeleteAfter: 0s # never auto-delete; the archive job handles it
|
||||||
|
# optional Discord "is live" notice (LIVE_WEBHOOK_URL set in .env). busybox wget in the -ffmpeg image.
|
||||||
|
runOnReady: >
|
||||||
|
sh -c '[ -n "$LIVE_WEBHOOK_URL" ] || exit 0;
|
||||||
|
u="${{MTX_PATH%%/*}}"; t="${{MTX_PATH#*/}}";
|
||||||
|
wget -q -O /dev/null --header="Content-Type: application/json"
|
||||||
|
--post-data="{{\\"content\\":\\"🔴 **$u** is live: $t — https://$LIVE_HOST/\\"}}" "$LIVE_WEBHOOK_URL"'
|
||||||
|
runOnReadyRestart: no
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
# any name under a streamer's prefix; inherits pathDefaults
|
# any name under a streamer's prefix; inherits pathDefaults
|
||||||
@@ -314,6 +365,10 @@ def write_copyparty(admin_pw, editors):
|
|||||||
rwmd: admin
|
rwmd: admin
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
def write_streamers_json(streamers):
|
||||||
|
import json as _json
|
||||||
|
Path("streamers.json").write_text(_json.dumps({u.lower(): p for u, p in streamers.items()}, indent=2) + "\n")
|
||||||
|
|
||||||
def write_dump_users(admin_pw, streamers):
|
def write_dump_users(admin_pw, streamers):
|
||||||
h = lambda p: hashlib.sha256(p.encode()).hexdigest()
|
h = lambda p: hashlib.sha256(p.encode()).hexdigest()
|
||||||
body = "# user:sha256hex(password) (generated by configure.py)\n"
|
body = "# user:sha256hex(password) (generated by configure.py)\n"
|
||||||
@@ -326,10 +381,11 @@ def run(cmd, **kw):
|
|||||||
if DRY: return 0
|
if DRY: return 0
|
||||||
return subprocess.call(cmd, **kw)
|
return subprocess.call(cmd, **kw)
|
||||||
|
|
||||||
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host=""):
|
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", live_webhook=None):
|
||||||
print("\n== writing configs")
|
print("\n== writing configs")
|
||||||
write_caddy(domain, auth_host, watch_host, git_host, bot_host); write_mediamtx(streamers); write_copyparty(admin_pw, editors); write_dump_users(admin_pw, streamers)
|
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(streamers); write_copyparty(admin_pw, editors); write_dump_users(admin_pw, streamers); write_streamers_json(streamers)
|
||||||
write_env(watch_host, discord_token, discord_guild, git_host, bot_host)
|
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook)
|
||||||
|
run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
|
||||||
print("== app data folders")
|
print("== app data folders")
|
||||||
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache",
|
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache",
|
||||||
"/srv/appdata/authentik/media", "/srv/appdata/authentik/templates", "/srv/appdata/authentik/certs"])
|
"/srv/appdata/authentik/media", "/srv/appdata/authentik/templates", "/srv/appdata/authentik/certs"])
|
||||||
@@ -343,14 +399,16 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
|
|||||||
print("== dump users + restart dumpd")
|
print("== dump users + restart dumpd")
|
||||||
run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"])
|
run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"])
|
||||||
run(["sudo", "install", "-m", "0640", "dump-users.txt", "/etc/vanguard/dump-users.txt"])
|
run(["sudo", "install", "-m", "0640", "dump-users.txt", "/etc/vanguard/dump-users.txt"])
|
||||||
|
run(["sudo", "install", "-m", "0600", "streamers.json", "/etc/vanguard/streamers.json"])
|
||||||
run(["sudo", "systemctl", "restart", "dumpd"])
|
run(["sudo", "systemctl", "restart", "dumpd"])
|
||||||
print("== containers")
|
print("== containers")
|
||||||
dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"]
|
dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"]
|
||||||
run(dc + ["up", "-d", "--build"])
|
run(dc + ["up", "-d", "--build"])
|
||||||
run(dc + ["restart", "mediamtx", "copyparty", "caddy"])
|
run(dc + ["restart", "mediamtx", "copyparty", "caddy"])
|
||||||
|
|
||||||
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host=""):
|
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
|
||||||
print(f"\n== domain : {domain or '(not set)'}")
|
print(f"\n== domain : {domain or '(not set)'}")
|
||||||
|
print(f"== live : https://{live_host}/ (behind Authentik forward-auth; deploy live/dist -> /srv/live; WebRTC needs 8189 open)" if live_host else "== live : (not set — run --full)")
|
||||||
print(f"== bot web : https://{bot_host}/ (behind Authentik forward-auth; create the Proxy Provider for it)" if bot_host else "== bot web : (not set — run --full)")
|
print(f"== bot web : https://{bot_host}/ (behind Authentik forward-auth; create the Proxy Provider for it)" if bot_host else "== bot web : (not set — run --full)")
|
||||||
print(f"== gitea : https://{git_host}/ ssh port 2222 (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)")
|
print(f"== gitea : https://{git_host}/ ssh port 2222 (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)")
|
||||||
print(f"== authentik: https://{auth_host}/ (first run: https://{auth_host}/if/flow/initial-setup/)" if auth_host else "== authentik: (not set — run --full)")
|
print(f"== authentik: https://{auth_host}/ (first run: https://{auth_host}/if/flow/initial-setup/)" if auth_host else "== authentik: (not set — run --full)")
|
||||||
@@ -393,7 +451,7 @@ def manage_users(kind, users):
|
|||||||
else:
|
else:
|
||||||
users[n] = ask_pw(n, users[n])
|
users[n] = ask_pw(n, users[n])
|
||||||
|
|
||||||
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host=""):
|
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
|
||||||
print("== FULL SETUP (enter keeps the shown default)\n")
|
print("== FULL SETUP (enter keeps the shown default)\n")
|
||||||
while True:
|
while True:
|
||||||
domain = ask("public hostname (DNS A record must point here)", domain)
|
domain = ask("public hostname (DNS A record must point here)", domain)
|
||||||
@@ -403,8 +461,10 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
|
|||||||
watch_host = ask("Jellyfin hostname (CNAME to the above)", watch_host or default_sub(domain, "watch"))
|
watch_host = ask("Jellyfin hostname (CNAME to the above)", watch_host or default_sub(domain, "watch"))
|
||||||
git_host = ask("Gitea hostname (CNAME to the above)", git_host or default_sub(domain, "git"))
|
git_host = ask("Gitea hostname (CNAME to the above)", git_host or default_sub(domain, "git"))
|
||||||
bot_host = ask("Bot web UI hostname (CNAME to the above)", bot_host or default_sub(domain, "bot"))
|
bot_host = ask("Bot web UI hostname (CNAME to the above)", bot_host or default_sub(domain, "bot"))
|
||||||
|
live_host = ask("Live portal hostname (CNAME to the above)", live_host or default_sub(domain, "live"))
|
||||||
print("\n-- Discord bot (optional; the Bot added to the Authentik Discord application). Enter keeps the current value.")
|
print("\n-- Discord bot (optional; the Bot added to the Authentik Discord application). Enter keeps the current value.")
|
||||||
discord_token, discord_guild = ask_discord()
|
discord_token, discord_guild = ask_discord()
|
||||||
|
live_webhook = ask(" Discord webhook URL for 'is live' notices (optional)", read_env().get("LIVE_WEBHOOK_URL", ""))
|
||||||
print("\n-- admin (copyparty admin + may dump anyone's night)")
|
print("\n-- admin (copyparty admin + may dump anyone's night)")
|
||||||
admin_pw = ask_pw("admin", admin_pw or None)
|
admin_pw = ask_pw("admin", admin_pw or None)
|
||||||
print("\n-- streamers (OBS publishers; each gets streams/<name> and clips/<name>)")
|
print("\n-- streamers (OBS publishers; each gets streams/<name> and clips/<name>)")
|
||||||
@@ -427,7 +487,7 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
|
|||||||
n = ask_name("editor", editors)
|
n = ask_name("editor", editors)
|
||||||
if not n: break
|
if not n: break
|
||||||
editors[n] = ask_pw(n)
|
editors[n] = ask_pw(n)
|
||||||
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host
|
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook
|
||||||
|
|
||||||
def ask_discord():
|
def ask_discord():
|
||||||
env = read_env()
|
env = read_env()
|
||||||
@@ -455,23 +515,24 @@ def normal(domain, admin_pw, streamers, editors):
|
|||||||
def main():
|
def main():
|
||||||
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
hosts = read_hosts()
|
hosts = read_hosts()
|
||||||
domain, auth_host, watch_host, git_host, bot_host = hosts.get("main", ""), hosts.get("auth", ""), hosts.get("watch", ""), hosts.get("git", ""), hosts.get("bot", "")
|
domain, auth_host, watch_host, git_host, bot_host, live_host = hosts.get("main", ""), hosts.get("auth", ""), hosts.get("watch", ""), hosts.get("git", ""), hosts.get("bot", ""), hosts.get("live", "")
|
||||||
streamers, accounts = read_streamers(), read_accounts()
|
streamers, accounts = read_streamers(), read_accounts()
|
||||||
admin_pw = accounts.pop("admin", "")
|
admin_pw = accounts.pop("admin", "")
|
||||||
editors = accounts
|
editors = accounts
|
||||||
if mode == "--show":
|
if mode == "--show":
|
||||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host); return
|
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
|
||||||
if mode == "--hosts":
|
if mode == "--hosts":
|
||||||
if len(sys.argv) not in (4, 5, 6): print("usage: configure.py --hosts AUTH_HOST WATCH_HOST [GIT_HOST [BOT_HOST]]"); sys.exit(2)
|
if len(sys.argv) not in (4, 5, 6, 7): print("usage: configure.py --hosts AUTH_HOST WATCH_HOST [GIT_HOST [BOT_HOST [LIVE_HOST]]]"); sys.exit(2)
|
||||||
if not domain or not admin_pw: print("domain/admin not set yet — run ./configure.py --full first"); sys.exit(1)
|
if not domain or not admin_pw: print("domain/admin not set yet — run ./configure.py --full first"); sys.exit(1)
|
||||||
auth_host, watch_host = sys.argv[2].strip(), sys.argv[3].strip()
|
auth_host, watch_host = sys.argv[2].strip(), sys.argv[3].strip()
|
||||||
if len(sys.argv) >= 5: git_host = sys.argv[4].strip()
|
if len(sys.argv) >= 5: git_host = sys.argv[4].strip()
|
||||||
if len(sys.argv) == 6: bot_host = sys.argv[5].strip()
|
if len(sys.argv) >= 6: bot_host = sys.argv[5].strip()
|
||||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host)
|
if len(sys.argv) == 7: live_host = sys.argv[6].strip()
|
||||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host); return
|
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host)
|
||||||
discord_token = discord_guild = None
|
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
|
||||||
|
discord_token = discord_guild = live_webhook = None
|
||||||
if mode == "--full":
|
if mode == "--full":
|
||||||
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host)
|
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host)
|
||||||
if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return
|
if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return
|
||||||
elif mode == "":
|
elif mode == "":
|
||||||
r = normal(domain, admin_pw, streamers, editors)
|
r = normal(domain, admin_pw, streamers, editors)
|
||||||
@@ -479,8 +540,8 @@ def main():
|
|||||||
discord_token, discord_guild = r
|
discord_token, discord_guild = r
|
||||||
else:
|
else:
|
||||||
print(__doc__); sys.exit(2)
|
print(__doc__); sys.exit(2)
|
||||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host)
|
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook)
|
||||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host)
|
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try: main()
|
try: main()
|
||||||
|
|||||||
11
deployLive.sh
Executable file
11
deployLive.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build the live portal here and push the static files to /srv/live on the server. Caddy serves them as-is.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/live"
|
||||||
|
HOST="${1:-debian@ovh1.dumpstervigilantes.rip}"
|
||||||
|
npm install --no-audit --no-fund --loglevel=error
|
||||||
|
npm run build
|
||||||
|
ssh "$HOST" 'sudo install -d -o "$(id -u)" -g "$(id -g)" /srv/live && rm -rf /srv/live/*'
|
||||||
|
scp -r dist/. "$HOST:/srv/live/"
|
||||||
|
ssh "$HOST" 'ls -la /srv/live | head -5'
|
||||||
|
echo "deployed -> https://$(ssh "$HOST" 'cd ~/vanguard-stream && grep ^LIVE_HOST= .env | cut -d= -f2')/"
|
||||||
@@ -15,7 +15,7 @@ systemctl enable nftables >/dev/null 2>&1 || true # loads /etc/nftables.conf a
|
|||||||
echo "== active ruleset (vanguard table)"
|
echo "== active ruleset (vanguard table)"
|
||||||
nft list table inet vanguard | sed 's/^/ /'
|
nft list table inet vanguard | sed 's/^/ /'
|
||||||
echo
|
echo
|
||||||
echo "== listening on public interfaces (should be 22 80 443 1935 2222 3306 8890 22000 only)"
|
echo "== listening on public interfaces (should be 22 80 443 1935 2222 3306 8189 8890 22000 only)"
|
||||||
ss -tulpnH | awk '$5 !~ /^(127\.|\[::1\])/ {print " "$1, $5}' | sort -u
|
ss -tulpnH | awk '$5 !~ /^(127\.|\[::1\])/ {print " "$1, $5}' | sort -u
|
||||||
echo
|
echo
|
||||||
echo "Now open a SECOND terminal and confirm: ssh debian@$(hostname -f 2>/dev/null || hostname)"
|
echo "Now open a SECOND terminal and confirm: ssh debian@$(hostname -f 2>/dev/null || hostname)"
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ table inet vanguard {
|
|||||||
udp dport 8890 accept # SRT ingest
|
udp dport 8890 accept # SRT ingest
|
||||||
tcp dport 22000 accept # Syncthing
|
tcp dport 22000 accept # Syncthing
|
||||||
tcp dport 2222 accept # Gitea ssh (git clone/push)
|
tcp dport 2222 accept # Gitea ssh (git clone/push)
|
||||||
|
udp dport 8189 accept # WebRTC media for the live portal
|
||||||
|
tcp dport 8189 accept # WebRTC ICE-TCP fallback
|
||||||
udp dport 22000 accept # Syncthing QUIC
|
udp dport 22000 accept # Syncthing QUIC
|
||||||
|
|
||||||
# mariadb (host network): allowlist first, then anywhere with a brute-force limit
|
# mariadb (host network): allowlist first, then anywhere with a brute-force limit
|
||||||
|
|||||||
2
live/.gitignore
vendored
Normal file
2
live/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
27
live/README.md
Normal file
27
live/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Vanguard Live — the live-stream portal
|
||||||
|
|
||||||
|
React + dockview page at `https://live.<domain>/` (behind Authentik): every active stream opens as a
|
||||||
|
dockable window; audio follows the focused window; per-window mute button and volume slider.
|
||||||
|
Playback is WebRTC (WHEP) with HLS fallback, straight from MediaMTX via Caddy.
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run build # -> dist/ (static; Caddy serves it)
|
||||||
|
npm run dev # local dev; VITE_MTX=http://<server> to proxy to a MediaMTX
|
||||||
|
|
||||||
|
Deploy: build here, `scp -r live/dist debian@HOST:~/vanguard-stream/live/`, Caddy picks it up (no restart).
|
||||||
|
|
||||||
|
Audio rules: all windows start muted; the focused window is unmuted at its slider volume. Clicking the
|
||||||
|
speaker on an unfocused window pins its audio on (yellow border) until you click it again. "Mute all"
|
||||||
|
silences everything until you focus a window; "Follow focus" clears pins.
|
||||||
|
|
||||||
|
Endpoints (proxied by Caddy, all behind the Authentik gate):
|
||||||
|
`/api/*` → MediaMTX API 9997 · `/webrtc/*` → WebRTC/WHEP 8889 · `/hls/*` → HLS 8888.
|
||||||
|
|
||||||
|
## Go Live (browser broadcast)
|
||||||
|
Toolbar → **Go Live**: closes all viewer windows, fetches the logged-in user's stream key from
|
||||||
|
`/me/stream-key` (dumpd, identity from Authentik's header), opens Chrome's window picker (windows only,
|
||||||
|
audio requested), and publishes over WebRTC/WHIP to `<user>/HelmetCam-<date>` at ≤12 Mbps, H.264, up to
|
||||||
|
1440p60. Recording, the "is live" post, the sweep and Jellyfin follow exactly as for an OBS stream.
|
||||||
|
Window audio: Chrome on Windows. Linux/Mac: pick a tab or the whole screen for sound.
|
||||||
|
Requires the Authentik username to equal the MediaMTX streamer name (configure.py keeps them in sync via
|
||||||
|
`/etc/vanguard/streamers.json`).
|
||||||
13
live/index.html
Normal file
13
live/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>Vanguard Live</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1890
live/package-lock.json
generated
Normal file
1890
live/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
live/package.json
Normal file
21
live/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "vanguard-live",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dockview-react": "^8.8.3",
|
||||||
|
"hls.js": "^1.6.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
live/src/App.jsx
Normal file
75
live/src/App.jsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { DockviewReact } from 'dockview-react'
|
||||||
|
import Player from './Player.jsx'
|
||||||
|
import { fetchStreams } from './api.js'
|
||||||
|
import { audio } from './useAudio.js'
|
||||||
|
import GoLive from './GoLive.jsx'
|
||||||
|
|
||||||
|
const components = { player: Player }
|
||||||
|
const POLL_MS = 3000
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const apiRef = useRef(null)
|
||||||
|
const [streams, setStreams] = useState([])
|
||||||
|
const [err, setErr] = useState(null)
|
||||||
|
const [mode, setMode] = useState('watch') // watch | golive
|
||||||
|
const focused = audio.use(s => s.focused)
|
||||||
|
|
||||||
|
// poll MediaMTX; open/close panels to match
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const list = await fetchStreams(); if (!alive) return
|
||||||
|
setErr(null); setStreams(list)
|
||||||
|
const api = apiRef.current; if (!api) return // (in golive mode the dock is unmounted: no viewer connections)
|
||||||
|
const have = new Set(api.panels.map(p => p.id))
|
||||||
|
const want = new Set(list.map(s => s.id))
|
||||||
|
for (const s of list) {
|
||||||
|
if (!have.has(s.id)) {
|
||||||
|
const last = api.panels[api.panels.length - 1]
|
||||||
|
api.addPanel({
|
||||||
|
id: s.id, component: 'player', title: `${s.user} · ${s.title}`,
|
||||||
|
params: s,
|
||||||
|
position: last ? { referencePanel: last.id, direction: api.panels.length % 2 ? 'below' : 'right' } : undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
api.getPanel(s.id)?.api.updateParameters(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of [...api.panels]) if (!want.has(p.id)) { api.removePanel(p); audio.forget(p.id) }
|
||||||
|
} catch (e) { if (alive) setErr(e.message) }
|
||||||
|
}
|
||||||
|
tick(); const t = setInterval(tick, POLL_MS)
|
||||||
|
return () => { alive = false; clearInterval(t) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onReady = useCallback(e => {
|
||||||
|
apiRef.current = e.api
|
||||||
|
e.api.onDidActivePanelChange(p => audio.focus(p?.id ?? null))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<div className="topbar">
|
||||||
|
<h1>Vanguard Live</h1>
|
||||||
|
<span className="count">{streams.length ? `${streams.length} live` : 'nobody is live'}{err ? ` · ${err}` : ''}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="count">audio follows the focused window</span>
|
||||||
|
<button onClick={() => audio.muteAll()} title="mute every window until you click one">Mute all</button>
|
||||||
|
<button onClick={() => audio.followFocus()} title="clear pinned audio; only the focused window plays sound">Follow focus</button>
|
||||||
|
<button className="golive-btn" onClick={() => { audio.muteAll(); apiRef.current = null; setMode('golive') }} title="stop watching and broadcast a window from this browser">Go Live</button>
|
||||||
|
</div>
|
||||||
|
{mode === 'golive' ? (
|
||||||
|
<GoLive onExit={() => { audio.followFocus(); setMode('watch') }} />
|
||||||
|
) : (<>
|
||||||
|
{streams.length === 0 && !err && (
|
||||||
|
<div className="empty"><h2>Nobody is live right now</h2><span>This page updates itself when a stream starts.</span></div>
|
||||||
|
)}
|
||||||
|
<div className="dock" style={{ display: streams.length ? 'block' : 'none' }}>
|
||||||
|
<DockviewReact className="dockview-theme-vanguard" components={components} onReady={onReady} />
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
live/src/GoLive.jsx
Normal file
89
live/src/GoLive.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { captureWindow, publish, fetchStreamKey, defaultTitle, cleanTitle, MAX_KBPS } from './broadcast.js'
|
||||||
|
|
||||||
|
// Broadcaster view: replaces the viewer grid while you're live.
|
||||||
|
export default function GoLive({ onExit }) {
|
||||||
|
const videoRef = useRef(null)
|
||||||
|
const sessionRef = useRef(null)
|
||||||
|
const [phase, setPhase] = useState('setup') // setup | capturing | live | error
|
||||||
|
const [title, setTitle] = useState(defaultTitle())
|
||||||
|
const [key, setKey] = useState(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [conn, setConn] = useState('')
|
||||||
|
const [stats, setStats] = useState(null)
|
||||||
|
const [started, setStarted] = useState(null)
|
||||||
|
const [elapsed, setElapsed] = useState(0)
|
||||||
|
const [hasAudio, setHasAudio] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => { fetchStreamKey().then(setKey).catch(e => { setErr(e.message); setPhase('error') }) }, [])
|
||||||
|
useEffect(() => { if (!started) return; const t = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000); return () => clearInterval(t) }, [started])
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'live') return
|
||||||
|
let last = { bytes: 0, t: Date.now() }
|
||||||
|
const t = setInterval(async () => {
|
||||||
|
const pc = sessionRef.current?.pc; if (!pc) return
|
||||||
|
for (const s of await pc.getStats()) if (s.type === 'outbound-rtp' && s.kind === 'video') {
|
||||||
|
const now = Date.now(); const kbps = Math.round((s.bytesSent - last.bytes) * 8 / ((now - last.t) || 1)); last = { bytes: s.bytesSent, t: now }
|
||||||
|
setStats({ kbps, fps: Math.round(s.framesPerSecond || 0), w: s.frameWidth || 0, h: s.frameHeight || 0, q: s.qualityLimitationReason })
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [phase])
|
||||||
|
useEffect(() => () => { sessionRef.current?.stop() }, [])
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setErr('')
|
||||||
|
const t = cleanTitle(title); if (!t) return setErr('give it a title')
|
||||||
|
if (!key) return setErr('no stream key')
|
||||||
|
try {
|
||||||
|
setPhase('capturing')
|
||||||
|
const stream = await captureWindow()
|
||||||
|
setHasAudio(stream.getAudioTracks().length > 0)
|
||||||
|
if (videoRef.current) { videoRef.current.srcObject = stream; videoRef.current.muted = true; videoRef.current.play().catch(() => {}) }
|
||||||
|
stream.getVideoTracks()[0].onended = () => stop() // user clicked Chrome's "Stop sharing"
|
||||||
|
const session = await publish({ stream, path: `${key.user}/${t}`, user: key.user, pass: key.pass, onState: setConn })
|
||||||
|
sessionRef.current = session
|
||||||
|
setStarted(Date.now()); setPhase('live')
|
||||||
|
} catch (e) { setErr(e.message); setPhase(e.message === 'capture cancelled' ? 'setup' : 'error') }
|
||||||
|
}
|
||||||
|
const stop = async () => { await sessionRef.current?.stop(); sessionRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; setPhase('setup'); setStarted(null); setStats(null); setConn('') }
|
||||||
|
|
||||||
|
const mmss = s => `${String(Math.floor(s / 3600)).padStart(2, '0')}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||||
|
return (
|
||||||
|
<div className="golive">
|
||||||
|
<div className="golive-head">
|
||||||
|
<h2>{phase === 'live' ? '🔴 You are live' : 'Go live from this browser'}</h2>
|
||||||
|
<span className="who">{key ? `as ${key.user}` : ''}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
{phase === 'live' ? <button className="danger" onClick={stop}>Stop stream</button>
|
||||||
|
: <button onClick={() => { sessionRef.current?.stop(); onExit() }}>Back to watching</button>}
|
||||||
|
</div>
|
||||||
|
<div className="golive-body">
|
||||||
|
<video ref={videoRef} autoPlay playsInline muted className={phase === 'live' || phase === 'capturing' ? 'show' : ''} />
|
||||||
|
{phase !== 'live' && (
|
||||||
|
<div className="golive-form">
|
||||||
|
<label>Stream title <small>(becomes the folder and the Jellyfin title)</small></label>
|
||||||
|
<input value={title} onChange={e => setTitle(e.target.value)} disabled={phase === 'capturing'} />
|
||||||
|
<div className="hint">
|
||||||
|
Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>.
|
||||||
|
Window audio works in Chrome on Windows; on Linux/Mac pick a tab or the whole screen for sound.
|
||||||
|
Capped at {MAX_KBPS / 1000} Mbps, up to 1440p60, H.264.
|
||||||
|
</div>
|
||||||
|
{err && <div className="err">{err}</div>}
|
||||||
|
<button className="primary" disabled={!key || phase === 'capturing'} onClick={start}>{phase === 'capturing' ? 'Waiting for the window picker…' : 'Start'}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{phase === 'live' && (
|
||||||
|
<div className="golive-stats">
|
||||||
|
<span className="pill live">LIVE</span>
|
||||||
|
<span>{mmss(elapsed)}</span>
|
||||||
|
<span>{key.user}/{cleanTitle(title)}</span>
|
||||||
|
<span>{conn}</span>
|
||||||
|
{stats && <span>{stats.w}×{stats.h} · {stats.fps} fps · {(stats.kbps / 1000).toFixed(1)} Mbps{stats.q && stats.q !== 'none' ? ` · limited by ${stats.q}` : ''}</span>}
|
||||||
|
{!hasAudio && <span className="warn">no audio captured — restart and tick "Share audio"</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
live/src/Player.jsx
Normal file
90
live/src/Player.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import Hls from 'hls.js'
|
||||||
|
import { whepUrl, hlsUrl } from './api.js'
|
||||||
|
import { audio, effectiveMuted } from './useAudio.js'
|
||||||
|
|
||||||
|
// WebRTC (WHEP) first, HLS fallback. Mute/volume come from the shared audio store.
|
||||||
|
export default function Player({ params, api }) {
|
||||||
|
const { id, user, title } = params
|
||||||
|
const videoRef = useRef(null)
|
||||||
|
const [status, setStatus] = useState('connecting…')
|
||||||
|
const [viewers, setViewers] = useState(params.viewers ?? 0)
|
||||||
|
const state = audio.use()
|
||||||
|
const muted = effectiveMuted(state, id)
|
||||||
|
const volume = state.streams[id]?.volume ?? 0.8
|
||||||
|
const override = state.streams[id]?.override
|
||||||
|
const focused = state.focused === id
|
||||||
|
|
||||||
|
useEffect(() => { audio.ensure(id) }, [id])
|
||||||
|
useEffect(() => { const d = api.onDidActiveChange?.(() => {}) ; return () => d?.dispose?.() }, [api])
|
||||||
|
useEffect(() => { const t = setInterval(() => setViewers(params.viewers ?? 0), 2000); return () => clearInterval(t) }, [params.viewers])
|
||||||
|
|
||||||
|
// apply audio state to the element
|
||||||
|
useEffect(() => {
|
||||||
|
const v = videoRef.current; if (!v) return
|
||||||
|
v.muted = muted; v.volume = volume
|
||||||
|
if (!muted && v.paused) v.play().catch(() => {})
|
||||||
|
}, [muted, volume])
|
||||||
|
|
||||||
|
// connect
|
||||||
|
useEffect(() => {
|
||||||
|
const v = videoRef.current; if (!v) return
|
||||||
|
let pc, hls, cancelled = false
|
||||||
|
const startHls = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
setStatus('hls')
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
hls = new Hls({ lowLatencyMode: true, liveSyncDurationCount: 2, enableWorker: true })
|
||||||
|
hls.loadSource(hlsUrl(id)); hls.attachMedia(v)
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}))
|
||||||
|
hls.on(Hls.Events.ERROR, (_, d) => { if (d.fatal) setStatus('hls error: ' + d.details) })
|
||||||
|
} else if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
v.src = hlsUrl(id); v.play().catch(() => {})
|
||||||
|
} else setStatus('no playback method')
|
||||||
|
}
|
||||||
|
const startWhep = async () => {
|
||||||
|
try {
|
||||||
|
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' })
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||||
|
pc.ontrack = e => { if (v.srcObject !== e.streams[0]) { v.srcObject = e.streams[0]; v.play().catch(() => {}) } }
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
if (pc.connectionState === 'connected') setStatus('')
|
||||||
|
if (['failed', 'disconnected', 'closed'].includes(pc.connectionState) && !cancelled) { pc.close(); startHls() }
|
||||||
|
}
|
||||||
|
const offer = await pc.createOffer()
|
||||||
|
await pc.setLocalDescription(offer)
|
||||||
|
await new Promise(res => { if (pc.iceGatheringState === 'complete') return res(); const t = setTimeout(res, 1500); pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res() } } })
|
||||||
|
const r = await fetch(whepUrl(id), { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
|
||||||
|
if (!r.ok) throw new Error('whep ' + r.status)
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
|
||||||
|
setStatus('webrtc')
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return
|
||||||
|
try { pc?.close() } catch {}
|
||||||
|
startHls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startWhep()
|
||||||
|
return () => { cancelled = true; try { pc?.close() } catch {}; try { hls?.destroy() } catch {}; v.srcObject = null; v.removeAttribute('src') }
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
const icon = muted ? '🔇' : volume === 0 ? '🔈' : volume < 0.5 ? '🔉' : '🔊'
|
||||||
|
return (
|
||||||
|
<div className={'player' + (focused ? ' focused' : '')} onMouseDown={() => api.setActive?.()}>
|
||||||
|
<video ref={videoRef} autoPlay playsInline muted />
|
||||||
|
{status && <div className="status">{status}</div>}
|
||||||
|
<div className="bar">
|
||||||
|
<span className="live">LIVE</span>
|
||||||
|
<span className="who">{user}</span>
|
||||||
|
<span className="title" title={title}>{title}</span>
|
||||||
|
<span className="viewers">👁 {viewers}</span>
|
||||||
|
<button className={'mute' + (!muted ? ' on' : '') + (override === 'unmuted' && !focused ? ' pinned' : '')}
|
||||||
|
title={override ? (override === 'unmuted' ? 'audio pinned on (click to mute)' : 'muted (click to unmute)') : (focused ? 'focused: audio on (click to mute)' : 'muted: click to pin audio on')}
|
||||||
|
onMouseDown={e => e.stopPropagation()} onClick={() => audio.toggleMute(id)}>{icon}</button>
|
||||||
|
<input type="range" min="0" max="1" step="0.02" value={volume} onMouseDown={e => e.stopPropagation()}
|
||||||
|
onChange={e => audio.setVolume(id, parseFloat(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
live/src/api.js
Normal file
23
live/src/api.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// MediaMTX control API, proxied by Caddy at /api (-> 127.0.0.1:9997). Returns active streams.
|
||||||
|
export async function fetchStreams() {
|
||||||
|
const r = await fetch('/api/v3/paths/list?itemsPerPage=200', { credentials: 'same-origin' })
|
||||||
|
if (!r.ok) throw new Error(`api ${r.status}`)
|
||||||
|
const data = await r.json()
|
||||||
|
return (data.items || [])
|
||||||
|
.filter(p => p.ready && (p.tracks || []).length > 0)
|
||||||
|
.map(p => {
|
||||||
|
const [user, ...rest] = p.name.split('/')
|
||||||
|
return {
|
||||||
|
id: p.name, // e.g. techy/VanguardTraining
|
||||||
|
user,
|
||||||
|
title: rest.join('/') || p.name,
|
||||||
|
viewers: (p.readers || []).length,
|
||||||
|
since: p.readyTime,
|
||||||
|
tracks: p.tracks || [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.since || '').localeCompare(b.since || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const whepUrl = id => `/webrtc/${id}/whep`
|
||||||
|
export const hlsUrl = id => `/hls/${id}/index.m3u8`
|
||||||
86
live/src/broadcast.js
Normal file
86
live/src/broadcast.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Publish a captured window to MediaMTX over WHIP (WebRTC), through Caddy at /webrtc/<path>/whip.
|
||||||
|
// Credentials go as query params so the Authentik forward-auth in front doesn't see an Authorization header.
|
||||||
|
|
||||||
|
export const MAX_KBPS = 12000
|
||||||
|
|
||||||
|
export function defaultTitle(now = new Date()) {
|
||||||
|
const d = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10)
|
||||||
|
return `HelmetCam-${d}`
|
||||||
|
}
|
||||||
|
export const cleanTitle = t => t.trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9_.-]/g, '')
|
||||||
|
|
||||||
|
export async function captureWindow() {
|
||||||
|
// Window-only picker with audio. Chrome on Windows captures that window's audio; elsewhere the user
|
||||||
|
// should pick a tab or the whole screen to get sound.
|
||||||
|
const constraints = {
|
||||||
|
video: { displaySurface: 'window', frameRate: { ideal: 60, max: 60 }, width: { max: 2560 }, height: { max: 1440 } },
|
||||||
|
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, sampleRate: 48000, channelCount: 2 },
|
||||||
|
systemAudio: 'include',
|
||||||
|
selfBrowserSurface: 'exclude',
|
||||||
|
monitorTypeSurfaces: 'exclude', // hide "Entire screen" so people pick the game window
|
||||||
|
surfaceSwitching: 'include',
|
||||||
|
preferCurrentTab: false,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await navigator.mediaDevices.getDisplayMedia(constraints)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'NotAllowedError') throw new Error('capture cancelled')
|
||||||
|
// older browsers choke on the newer hints; retry plain
|
||||||
|
return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 60 }, audio: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferH264(pc, kind = 'video') {
|
||||||
|
const caps = RTCRtpSender.getCapabilities?.(kind)
|
||||||
|
if (!caps) return
|
||||||
|
const tx = pc.getTransceivers().find(t => t.sender.track?.kind === kind)
|
||||||
|
if (!tx?.setCodecPreferences) return
|
||||||
|
const h264 = caps.codecs.filter(c => /H264/i.test(c.mimeType) && /packetization-mode=1/.test(c.sdpFmtpLine || ''))
|
||||||
|
const rest = caps.codecs.filter(c => !/H264/i.test(c.mimeType))
|
||||||
|
if (h264.length) tx.setCodecPreferences([...h264, ...rest]) // MediaMTX records H264 into fMP4; VP8 could not be recorded
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publish({ stream, path, user, pass, onState }) {
|
||||||
|
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
|
||||||
|
for (const track of stream.getTracks()) pc.addTransceiver(track, { direction: 'sendonly' })
|
||||||
|
preferH264(pc)
|
||||||
|
const vs = pc.getSenders().find(s => s.track?.kind === 'video')
|
||||||
|
if (vs) {
|
||||||
|
const p = vs.getParameters(); p.encodings = p.encodings?.length ? p.encodings : [{}]
|
||||||
|
p.encodings[0].maxBitrate = MAX_KBPS * 1000
|
||||||
|
p.encodings[0].maxFramerate = 60
|
||||||
|
p.degradationPreference = 'maintain-framerate'
|
||||||
|
await vs.setParameters(p).catch(() => {})
|
||||||
|
}
|
||||||
|
pc.onconnectionstatechange = () => onState?.(pc.connectionState)
|
||||||
|
const offer = await pc.createOffer()
|
||||||
|
await pc.setLocalDescription(offer)
|
||||||
|
await new Promise(res => { if (pc.iceGatheringState === 'complete') return res(); const t = setTimeout(res, 2000); pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res() } } })
|
||||||
|
const url = `/webrtc/${path}/whip?user=${encodeURIComponent(user)}&pass=${encodeURIComponent(pass)}`
|
||||||
|
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
|
||||||
|
if (!r.ok) { pc.close(); throw new Error(r.status === 401 || r.status === 403 ? 'stream key rejected' : r.status === 400 && /busy|already/i.test(await r.text().catch(() => '')) ? 'that stream name is already live (OBS running?)' : `publish failed (${r.status})`) }
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
|
||||||
|
const session = r.headers.get('Location')
|
||||||
|
return {
|
||||||
|
pc,
|
||||||
|
stats: async () => {
|
||||||
|
let kbps = 0, fps = 0, w = 0, h = 0
|
||||||
|
for (const s of await pc.getStats()) {
|
||||||
|
if (s.type === 'outbound-rtp' && s.kind === 'video') { fps = s.framesPerSecond || 0; w = s.frameWidth || 0; h = s.frameHeight || 0; kbps = s.__k || 0 }
|
||||||
|
}
|
||||||
|
return { kbps, fps, w, h }
|
||||||
|
},
|
||||||
|
stop: async () => {
|
||||||
|
try { if (session) await fetch(session.startsWith('http') ? new URL(session).pathname : session, { method: 'DELETE', credentials: 'same-origin' }) } catch {}
|
||||||
|
pc.close()
|
||||||
|
stream.getTracks().forEach(t => t.stop())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStreamKey() {
|
||||||
|
const r = await fetch('/me/stream-key', { credentials: 'same-origin' })
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok) throw new Error(body.error || `stream key ${r.status}`)
|
||||||
|
return body
|
||||||
|
}
|
||||||
7
live/src/main.jsx
Normal file
7
live/src/main.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import 'dockview/dist/styles/dockview.css'
|
||||||
|
import './styles.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(<App />)
|
||||||
55
live/src/styles.css
Normal file
55
live/src/styles.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
:root { --bg:#141414; --panel:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --live:#e74c3c; }
|
||||||
|
html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg); font:14px/1.4 system-ui, sans-serif; }
|
||||||
|
.app { display:flex; flex-direction:column; height:100%; }
|
||||||
|
.topbar { display:flex; align-items:center; gap:14px; padding:8px 14px; background:#101010; border-bottom:1px solid #2a2a2a; }
|
||||||
|
.topbar h1 { font-size:15px; margin:0; font-weight:700; letter-spacing:.3px; }
|
||||||
|
.topbar .count { color:var(--mut); font-size:13px; }
|
||||||
|
.topbar .spacer { flex:1; }
|
||||||
|
.topbar button { background:#2a2a2a; color:var(--fg); border:1px solid #3a3a3a; border-radius:6px; padding:5px 10px; cursor:pointer; font-size:13px; }
|
||||||
|
.topbar button:hover { border-color:var(--acc); }
|
||||||
|
.dock { flex:1; min-height:0; }
|
||||||
|
.dockview-theme-vanguard { --dv-background-color:var(--bg); --dv-paneview-active-outline-color:var(--acc);
|
||||||
|
--dv-tabs-and-actions-container-background-color:#181818; --dv-activegroup-visiblepanel-tab-background-color:var(--panel);
|
||||||
|
--dv-activegroup-hiddenpanel-tab-background-color:#181818; --dv-inactivegroup-visiblepanel-tab-background-color:#1a1a1a;
|
||||||
|
--dv-inactivegroup-hiddenpanel-tab-background-color:#181818; --dv-tab-divider-color:#2a2a2a; --dv-activegroup-visiblepanel-tab-color:var(--fg);
|
||||||
|
--dv-activegroup-hiddenpanel-tab-color:var(--mut); --dv-inactivegroup-visiblepanel-tab-color:var(--mut); --dv-inactivegroup-hiddenpanel-tab-color:#777;
|
||||||
|
--dv-separator-border:#2a2a2a; --dv-group-view-background-color:var(--panel); --dv-drag-over-background-color:rgba(61,139,253,.2);
|
||||||
|
--dv-drag-over-border-color:var(--acc); --dv-tabs-container-scrollbar-color:#444; --dv-icon-hover-background-color:#333; }
|
||||||
|
.player { position:relative; width:100%; height:100%; background:#000; display:flex; flex-direction:column; }
|
||||||
|
.player video { flex:1; min-height:0; width:100%; height:100%; object-fit:contain; background:#000; }
|
||||||
|
.player .bar { display:flex; align-items:center; gap:10px; padding:6px 10px; background:#181818; border-top:1px solid #2a2a2a; font-size:13px; }
|
||||||
|
.player .live { background:var(--live); color:#fff; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; letter-spacing:.5px; }
|
||||||
|
.player .who { font-weight:600; }
|
||||||
|
.player .title { color:var(--mut); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0; }
|
||||||
|
.player .viewers { color:var(--mut); }
|
||||||
|
.player .mute { background:#2a2a2a; color:var(--fg); border:1px solid #3a3a3a; border-radius:6px; width:34px; height:28px; cursor:pointer; font-size:15px; }
|
||||||
|
.player .mute.on { border-color:var(--acc); }
|
||||||
|
.player .mute.pinned { border-color:#f1c40f; }
|
||||||
|
.player input[type=range] { width:110px; accent-color:var(--acc); }
|
||||||
|
.player .status { position:absolute; top:8px; left:8px; background:rgba(0,0,0,.6); color:var(--mut); font-size:12px; padding:3px 8px; border-radius:4px; }
|
||||||
|
.player.focused .bar { background:#1c2333; }
|
||||||
|
.empty { flex:1; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:8px; color:var(--mut); }
|
||||||
|
.empty h2 { color:var(--fg); margin:0; font-size:20px; }
|
||||||
|
|
||||||
|
/* --- Go Live --------------------------------------------------------------------------------- */
|
||||||
|
.golive { flex:1; display:flex; flex-direction:column; min-height:0; }
|
||||||
|
.golive-head { display:flex; align-items:center; gap:12px; padding:10px 14px; background:#101010; border-bottom:1px solid #2a2a2a; }
|
||||||
|
.golive-head h2 { margin:0; font-size:16px; }
|
||||||
|
.golive-head .who { color:var(--mut); }
|
||||||
|
.golive-head .spacer { flex:1; }
|
||||||
|
.golive-head button, .golive-form button { background:#2a2a2a; color:var(--fg); border:1px solid #3a3a3a; border-radius:6px; padding:8px 14px; cursor:pointer; font-size:14px; }
|
||||||
|
.golive-head button.danger { background:var(--live); border-color:var(--live); color:#fff; font-weight:600; }
|
||||||
|
.golive-form button.primary { background:var(--acc); border-color:var(--acc); color:#fff; font-weight:600; width:100%; padding:12px; margin-top:8px; }
|
||||||
|
.golive-form button:disabled { opacity:.5; cursor:default; }
|
||||||
|
.golive-body { flex:1; position:relative; display:flex; align-items:center; justify-content:center; min-height:0; background:#000; }
|
||||||
|
.golive-body video { display:none; width:100%; height:100%; object-fit:contain; }
|
||||||
|
.golive-body video.show { display:block; }
|
||||||
|
.golive-form { position:absolute; width:min(520px, calc(100% - 32px)); background:var(--panel); border:1px solid #3a3a3a; border-radius:12px; padding:22px; display:grid; gap:10px; }
|
||||||
|
.golive-form label { color:var(--mut); font-size:13px; }
|
||||||
|
.golive-form input { padding:10px 12px; font-size:15px; background:#141414; color:var(--fg); border:1px solid #444; border-radius:8px; }
|
||||||
|
.golive-form .hint { color:var(--mut); font-size:13px; line-height:1.5; }
|
||||||
|
.golive-form .err { color:var(--live); font-size:13px; }
|
||||||
|
.golive-stats { position:absolute; left:12px; bottom:12px; display:flex; gap:14px; align-items:center; background:rgba(0,0,0,.65); padding:8px 12px; border-radius:8px; font-size:13px; }
|
||||||
|
.golive-stats .pill.live { background:var(--live); color:#fff; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; }
|
||||||
|
.golive-stats .warn { color:#f1c40f; }
|
||||||
|
.topbar button.golive-btn { background:var(--live); border-color:var(--live); color:#fff; font-weight:700; }
|
||||||
22
live/src/tinyStore.js
Normal file
22
live/src/tinyStore.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
// 40-line store: state object + action methods that return partial updates.
|
||||||
|
export function create(def) {
|
||||||
|
let state = {}
|
||||||
|
const listeners = new Set()
|
||||||
|
const api = {}
|
||||||
|
for (const [k, v] of Object.entries(def)) {
|
||||||
|
if (typeof v === 'function') {
|
||||||
|
api[k] = (...args) => {
|
||||||
|
const patch = v.apply(state, args)
|
||||||
|
if (patch) { state = { ...state, ...patch }; listeners.forEach(l => l()) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.get = () => state
|
||||||
|
api.subscribe = l => { listeners.add(l); return () => listeners.delete(l) }
|
||||||
|
api.use = (selector = s => s) => useSyncExternalStore(api.subscribe, () => selector(state), () => selector(state))
|
||||||
|
return api
|
||||||
|
}
|
||||||
46
live/src/useAudio.js
Normal file
46
live/src/useAudio.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { create } from './tinyStore.js'
|
||||||
|
|
||||||
|
// Per-stream audio state. Effective mute = override ?? !focused.
|
||||||
|
// override: null (follow focus) | 'unmuted' (pinned on) | 'muted' (pinned off)
|
||||||
|
export const audio = create({
|
||||||
|
focused: null,
|
||||||
|
streams: {}, // id -> { volume: 0..1, override }
|
||||||
|
focus(id) {
|
||||||
|
const s = { ...this.streams }
|
||||||
|
if (id && !s[id]) s[id] = { volume: 0.8, override: null }
|
||||||
|
if (id && s[id]) s[id] = { ...s[id], override: null } // focusing always gives you sound
|
||||||
|
return { focused: id, streams: s }
|
||||||
|
},
|
||||||
|
ensure(id) {
|
||||||
|
if (this.streams[id]) return null
|
||||||
|
return { streams: { ...this.streams, [id]: { volume: 0.8, override: null } } }
|
||||||
|
},
|
||||||
|
forget(id) {
|
||||||
|
const s = { ...this.streams }; delete s[id]
|
||||||
|
return { streams: s, focused: this.focused === id ? null : this.focused }
|
||||||
|
},
|
||||||
|
setVolume(id, volume) {
|
||||||
|
const cur = this.streams[id] || { volume: 0.8, override: null }
|
||||||
|
return { streams: { ...this.streams, [id]: { ...cur, volume } } }
|
||||||
|
},
|
||||||
|
toggleMute(id) {
|
||||||
|
const cur = this.streams[id] || { volume: 0.8, override: null }
|
||||||
|
const effectiveMuted = cur.override ? cur.override === 'muted' : this.focused !== id
|
||||||
|
return { streams: { ...this.streams, [id]: { ...cur, override: effectiveMuted ? 'unmuted' : 'muted' } } }
|
||||||
|
},
|
||||||
|
muteAll() {
|
||||||
|
const s = {}; for (const k in this.streams) s[k] = { ...this.streams[k], override: 'muted' }
|
||||||
|
return { streams: s }
|
||||||
|
},
|
||||||
|
followFocus() {
|
||||||
|
const s = {}; for (const k in this.streams) s[k] = { ...this.streams[k], override: null }
|
||||||
|
return { streams: s }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function effectiveMuted(state, id) {
|
||||||
|
const cur = state.streams[id]
|
||||||
|
if (!cur) return true
|
||||||
|
if (cur.override) return cur.override === 'muted'
|
||||||
|
return state.focused !== id
|
||||||
|
}
|
||||||
17
live/vite.config.js
Normal file
17
live/vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// Dev: `npm run dev` proxies the three MediaMTX endpoints to a running server (set VITE_MTX=https://live.example.com
|
||||||
|
// to hit production through Caddy, cookies permitting) or to a local MediaMTX.
|
||||||
|
const target = process.env.VITE_MTX || 'http://127.0.0.1'
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: { outDir: 'dist', emptyOutDir: true },
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: `${target}:9997`, changeOrigin: true, rewrite: p => p.replace(/^\/api/, '') },
|
||||||
|
'/webrtc': { target: `${target}:8889`, changeOrigin: true, rewrite: p => p.replace(/^\/webrtc/, '') },
|
||||||
|
'/hls': { target: `${target}:8888`, changeOrigin: true, rewrite: p => p.replace(/^\/hls/, '') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
29
mediamtx.yml
29
mediamtx.yml
@@ -2,12 +2,23 @@
|
|||||||
logLevel: info
|
logLevel: info
|
||||||
|
|
||||||
rtsp: no
|
rtsp: no
|
||||||
webrtc: no
|
|
||||||
hls: no
|
|
||||||
api: no
|
|
||||||
metrics: no
|
metrics: no
|
||||||
pprof: no
|
pprof: no
|
||||||
|
|
||||||
|
# live portal (served to the browser only via Caddy + Authentik on live.HOST; these ports are published on 127.0.0.1)
|
||||||
|
api: yes
|
||||||
|
apiAddress: :9997
|
||||||
|
hls: yes
|
||||||
|
hlsAddress: :8888
|
||||||
|
hlsVariant: lowLatency
|
||||||
|
hlsAlwaysRemux: no
|
||||||
|
webrtc: yes
|
||||||
|
webrtcAddress: :8889
|
||||||
|
webrtcLocalUDPAddress: :8189
|
||||||
|
webrtcLocalTCPAddress: :8189
|
||||||
|
webrtcIPsFromInterfaces: no
|
||||||
|
# webrtcAdditionalHosts is set via env MTX_WEBRTCADDITIONALHOSTS (= the public hostname) in docker-compose.yml
|
||||||
|
|
||||||
rtmp: yes
|
rtmp: yes
|
||||||
rtmpAddress: :1935
|
rtmpAddress: :1935
|
||||||
|
|
||||||
@@ -20,6 +31,11 @@ srtAddress: :8890
|
|||||||
# SRT: srt://HOST:8890?streamid=publish:techy/Title:techy:CHANGEME
|
# SRT: srt://HOST:8890?streamid=publish:techy/Title:techy:CHANGEME
|
||||||
authMethod: internal
|
authMethod: internal
|
||||||
authInternalUsers:
|
authInternalUsers:
|
||||||
|
- user: any
|
||||||
|
permissions:
|
||||||
|
- action: read
|
||||||
|
- action: playback
|
||||||
|
- action: api
|
||||||
- user: techy
|
- user: techy
|
||||||
pass: CHANGEME_TECHY
|
pass: CHANGEME_TECHY
|
||||||
permissions:
|
permissions:
|
||||||
@@ -39,6 +55,13 @@ pathDefaults:
|
|||||||
recordPartDuration: 1s
|
recordPartDuration: 1s
|
||||||
recordSegmentDuration: 12h # one file per stream unless it runs >12h
|
recordSegmentDuration: 12h # one file per stream unless it runs >12h
|
||||||
recordDeleteAfter: 0s # never auto-delete; the archive job handles it
|
recordDeleteAfter: 0s # never auto-delete; the archive job handles it
|
||||||
|
# optional Discord "is live" notice (LIVE_WEBHOOK_URL set in .env). busybox wget in the -ffmpeg image.
|
||||||
|
runOnReady: >
|
||||||
|
sh -c '[ -n "$LIVE_WEBHOOK_URL" ] || exit 0;
|
||||||
|
u="${MTX_PATH%%/*}"; t="${MTX_PATH#*/}";
|
||||||
|
wget -q -O /dev/null --header="Content-Type: application/json"
|
||||||
|
--post-data="{\"content\":\"🔴 **$u** is live: $t — https://$LIVE_HOST/\"}" "$LIVE_WEBHOOK_URL"'
|
||||||
|
runOnReadyRestart: no
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
# any name under a streamer's prefix; inherits pathDefaults
|
# any name under a streamer's prefix; inherits pathDefaults
|
||||||
|
|||||||
Reference in New Issue
Block a user