Compare commits
3 Commits
37b619055b
...
f06ebc1ec1
| Author | SHA1 | Date | |
|---|---|---|---|
| f06ebc1ec1 | |||
| 4b4c7eaedf | |||
| dff31b0b37 |
@@ -261,6 +261,26 @@ Same pattern as Jellyfin: Authentik is the identity, Gitea trusts it.
|
|||||||
- [ ] policy on the Authentik application: who may reach Gitea at all (e.g. `admins` + a `devs` group)
|
- [ ] policy on the Authentik application: who may reach Gitea at all (e.g. `admins` + a `devs` group)
|
||||||
- [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group
|
- [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group
|
||||||
|
|
||||||
|
### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere)
|
||||||
|
Goal: nobody holds a MediaMTX password. "May publish" = Authentik identity + `streamers` group (filled by
|
||||||
|
Discord roles); the publish path must start with the user's own name.
|
||||||
|
- MediaMTX `authMethod: http`, `authHTTPAddress: http://127.0.0.1:8080/mtx-auth` (dumpd becomes the
|
||||||
|
arbiter; reads stay open to `any`, Caddy gates viewers). MediaMTX posts user/pass/token/action/path/ip.
|
||||||
|
- **Browser (Go Live, WHEP)**: Caddy forwards Authentik's `X-Authentik-Jwt` as `Authorization: Bearer` on
|
||||||
|
the `/webrtc` hop. dumpd verifies the JWT against Authentik's JWKS (`/application/o/<slug>/jwks/`),
|
||||||
|
reads `preferred_username` + `groups`, allows publish iff `path` starts with `<username>/` and user ∈ streamers.
|
||||||
|
No `/me/stream-key`, no `streamers.json`, nothing in the browser.
|
||||||
|
- **OBS**: streamer creates an **app password** in their Authentik profile and pastes it in the dock once;
|
||||||
|
dumpd validates Basic `user:app-password` against Authentik (outpost basic-auth endpoint or the API),
|
||||||
|
same path rule. Revoking the app password revokes the stream key.
|
||||||
|
- configure.py loses the streamers section (folders are created on first publish by MediaMTX anyway;
|
||||||
|
the sweep and Jellyfin cope). Dock: "paste your Authentik app password" instead of a stream password.
|
||||||
|
- [ ] dumpd `/mtx-auth` (JWT via JWKS, Basic via Authentik, path rule, 5 s cache)
|
||||||
|
- [ ] Caddy: `header_up Authorization "Bearer {http.request.header.X-Authentik-Jwt}"` on `/webrtc/*` (replaces X-Stream-Auth)
|
||||||
|
- [ ] MediaMTX `authMethod: http`; keep `any` read; drop `authInternalUsers`
|
||||||
|
- [ ] dock + README; remove streamers from configure.py; delete `streamers.json`
|
||||||
|
- [ ] test: OBS with an app password; Go Live with no key; a non-streamer gets denied
|
||||||
|
|
||||||
### Option — more clip sources (Medal, Twitch/YouTube/Kick clips, Discord uploads)
|
### Option — more clip sources (Medal, Twitch/YouTube/Kick clips, Discord uploads)
|
||||||
|
|
||||||
Today the only clip intake is a local clipper folder synced by Syncthing. Members without that
|
Today the only clip intake is a local clipper folder synced by Syncthing. Members without that
|
||||||
|
|||||||
23
STATUS.md
23
STATUS.md
@@ -1,6 +1,6 @@
|
|||||||
# Vanguard stack — what runs, what people do, what's unfinished
|
# Vanguard stack — what runs, what people do, what's unfinished
|
||||||
|
|
||||||
Living document. Updated whenever something is deployed or changes behaviour. Last update: 2026-10-30 (evening).
|
Living document. Updated whenever something is deployed or changes behaviour. Last update: 2026-10-31.
|
||||||
Host: `ovh1.dumpstervigilantes.rip` (OVH SYS-1, Debian 13). Bundle: `~/vanguard-stream` on the box.
|
Host: `ovh1.dumpstervigilantes.rip` (OVH SYS-1, Debian 13). Bundle: `~/vanguard-stream` on the box.
|
||||||
Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea (`ssh://git@git.dumpstervigilantes.rip:2222/techy/Media-Server-Setup.git`).
|
Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea (`ssh://git@git.dumpstervigilantes.rip:2222/techy/Media-Server-Setup.git`).
|
||||||
|
|
||||||
@@ -17,7 +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**) |
|
| 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 (page deployed; MediaMTX port fix pending apply) |
|
||||||
| **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 |
|
||||||
|
|
||||||
@@ -30,8 +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** |
|
| 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, domain-level) — live, first real stream untested |
|
||||||
| 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** |
|
| 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 — awaiting MediaMTX port fix |
|
||||||
| 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 |
|
||||||
@@ -58,13 +58,14 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
|||||||
| Gitea | git hosting on MariaDB, Actions enabled; holds this repo | live, local admin |
|
| Gitea | git hosting on MariaDB, Actions enabled; holds this repo | live, local admin |
|
||||||
| 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 requires group `bot-users` (from a Discord role) | Caddy block ready; **CNAME + Authentik domain-level provider + roles→groups policy pending** |
|
| `bot.` host | bot web UI behind Authentik forward-auth; Caddy requires group `bot-users` (from a Discord role) | Caddy block live; **`bot` CNAME still points at the wrong IP; roles→groups policy 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** |
|
| Live portal (`live/`, `live.` host) | React + dockview multi-stream viewer + browser Go Live (WHIP); MediaMTX HLS/WebRTC/API on loopback; dumpd `/me/stream-key` | page live behind Authentik (domain-level); **MediaMTX recreate with new ports 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. **Recreate MediaMTX** with the corrected compose (loopback HLS/WebRTC/API ports) → portal list + Go Live work.
|
||||||
1b. **Live portal**: CNAME `live`, `./configure.py --full` (live hostname + optional webhook), Authentik proxy provider for `https://live.HOST`, `./deployLive.sh`.
|
1b. **`bot` CNAME** → `ovh1`; Authentik `discord-roles-to-groups` policy + `bot-users` group.
|
||||||
|
1c. Confirm plugin 0.1.2 installed and runner registered (were in the same deploy block).
|
||||||
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.
|
||||||
@@ -75,9 +76,13 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
|||||||
9. **Home archive puller** on the home box.
|
9. **Home archive puller** on the home box.
|
||||||
10. **Plugin repository** served by Caddy so Jellyfin updates the plugin from its catalog.
|
10. **Plugin repository** served by Caddy so Jellyfin updates the plugin from its catalog.
|
||||||
11. **Clip submissions** (Medal/Twitch/YouTube links, Discord uploads) — belongs to the bot.
|
11. **Clip submissions** (Medal/Twitch/YouTube links, Discord uploads) — belongs to the bot.
|
||||||
12. **SYS-3** when transcoding or sweep speed hurts: Quick Sync, flip the plugin's policy toggle off.
|
12. **MediaMTX auth through Authentik** (plan phase 11): no stream keys; Go Live uses the Authentik JWT, OBS uses an Authentik app password; "streamer" = group membership from a Discord role.
|
||||||
|
13. **SYS-3** when transcoding or sweep speed hurts: Quick Sync, flip the plugin's policy toggle off.
|
||||||
|
|
||||||
## Verified behaviours worth remembering
|
## Verified behaviours worth remembering
|
||||||
|
- **Never scp generated configs** (`mediamtx.yml`, `copyparty.conf`, `Caddyfile`, `dump-users.txt`, `.env`): a template copy wiped all streamers once. They are gitignored now; `*.example.*` are reference only.
|
||||||
|
- Caddy runs in a container: anything it serves from disk must be a mount (`/srv/live` was missing → 404).
|
||||||
|
- MediaMTX 1.21 refuses to start without `webrtcAdditionalHosts` or `webrtcIPsFromInterfaces`; configure.py writes the public host in.
|
||||||
- Jellyfin honours provider-supplied season numbers on a Shows library (day seasons appeared). Next check: Monday 04:10 ET should fold them into a week season.
|
- Jellyfin honours provider-supplied season numbers on a Shows library (day seasons appeared). Next check: Monday 04:10 ET should fold them into a week season.
|
||||||
- A stream indexed by Jellyfin while still recording has no duration and skips to the next episode on seek — fixed by recording into `.live/` so nothing sees a file until it is finished and flat.
|
- A stream indexed by Jellyfin while still recording has no duration and skips to the next episode on seek — fixed by recording into `.live/` so nothing sees a file until it is finished and flat.
|
||||||
- Sweep leaves compliant Syncthing files untouched and keeps exactly one copy of converted ones (tested).
|
- Sweep leaves compliant Syncthing files untouched and keeps exactly one copy of converted ones (tested).
|
||||||
|
|||||||
@@ -150,18 +150,26 @@ 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 FORWARD-AUTH (gates https://$LIVE/ and https://$BOT/; later the main host) — once:
|
||||||
Authentik -> Applications -> Create with provider -> Proxy Provider (forward auth, single application),
|
1. Applications -> Outposts -> edit "authentik Embedded Outpost" -> config: authentik_host: https://$AUTH
|
||||||
External host https://$LIVE -> add to the embedded outpost -> group policy.
|
2. Applications -> Create with provider -> Application "Vanguard sites"; Proxy Provider, authorization
|
||||||
Deploy the page: on the dev box `cd live && npm run build`, then ./deployLive.sh (scp dist -> /srv/live).
|
flow implicit-consent, mode "Forward auth (domain level)", authentication URL https://$AUTH,
|
||||||
WebRTC viewers need UDP/TCP 8189 open (firewall rule included).
|
cookie domain ${BASE} -> then edit the Embedded Outpost -> Applications: add it -> Update
|
||||||
|
3. Per-host access is enforced by Caddy from X-Authentik-Groups: .env BOT_REQUIRE_GROUP=bot-users
|
||||||
|
(default), LIVE_REQUIRE_GROUP= (empty = any logged-in member). ./configure.py apply regenerates.
|
||||||
|
4. Discord roles -> Authentik groups: Directory -> Groups: create bot-users (keep streamers/editors/admins);
|
||||||
|
Discord source -> Scopes: "guilds guilds.members.read"; Customization -> Policies -> Expression Policy
|
||||||
|
"discord-roles-to-groups" from authentik/discord-roles-to-groups.py (server id + role-id->group table);
|
||||||
|
bind to default-source-enrollment AND default-source-authentication (engine mode all).
|
||||||
|
5. Test: private window -> https://$LIVE/ -> Discord -> portal; https://$BOT/ -> 403 unless you hold the
|
||||||
|
bot role; https://$LIVE/me/stream-key shows your username.
|
||||||
|
|
||||||
BOT WEB UI (https://$BOT/) — Authentik forward-auth gate, create once:
|
LIVE PORTAL (https://$LIVE/)
|
||||||
Authentik -> Applications -> Create with provider -> Proxy Provider
|
Deploy the page from the dev box: ./deployLive.sh (builds live/ and copies dist/ to /srv/live).
|
||||||
mode "Forward auth (single application)", External host https://$BOT
|
WebRTC viewers and Go Live need UDP/TCP 8189 open (firewall rule included). Optional "is live" Discord
|
||||||
-> Applications -> Outposts -> edit "authentik Embedded Outpost" -> add the new application -> Update
|
post: LIVE_WEBHOOK_URL in .env (asked by configure.py --full).
|
||||||
-> policy on the application: groups admins / editors / streamers as you see fit
|
|
||||||
Until this exists the site returns an error (never open). The bot receives X-Authentik-Username/-Groups/-Email.
|
BOT WEB UI (https://$BOT/) — gated by the forward-auth above + group bot-users; the bot dev's brief is bot/AGENT.md.
|
||||||
|
|
||||||
GITEA (git.$GIT)
|
GITEA (git.$GIT)
|
||||||
1. https://$GIT/ -> the first visit shows the install page: database is pre-filled from env; set the
|
1. https://$GIT/ -> the first visit shows the install page: database is pre-filled from env; set the
|
||||||
|
|||||||
@@ -164,6 +164,13 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
|
|||||||
reverse_proxy 127.0.0.1:9997
|
reverse_proxy 127.0.0.1:9997
|
||||||
}}
|
}}
|
||||||
handle_path /webrtc/* {{
|
handle_path /webrtc/* {{
|
||||||
|
# Go Live sends MediaMTX credentials in X-Stream-Auth so Authentik's forward-auth never sees an
|
||||||
|
# Authorization header; Caddy turns it into Authorization only on the hop to MediaMTX.
|
||||||
|
@creds header X-Stream-Auth *
|
||||||
|
reverse_proxy @creds 127.0.0.1:8889 {{
|
||||||
|
header_up Authorization {{http.request.header.X-Stream-Auth}}
|
||||||
|
header_up -X-Stream-Auth
|
||||||
|
}}
|
||||||
reverse_proxy 127.0.0.1:8889
|
reverse_proxy 127.0.0.1:8889
|
||||||
}}
|
}}
|
||||||
handle_path /hls/* {{
|
handle_path /hls/* {{
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ install -d -o 1000 -g 1000 /srv/media/{streams,clips,finals,trash,.markers,.sync
|
|||||||
install -d -o 1000 -g 1000 /srv/appdata/jellyfin/{config,cache} /srv/appdata/authentik/{media,templates,certs}
|
install -d -o 1000 -g 1000 /srv/appdata/jellyfin/{config,cache} /srv/appdata/authentik/{media,templates,certs}
|
||||||
install -d /srv/appdata/authentik/{db,redis} /srv/appdata/mariadb
|
install -d /srv/appdata/authentik/{db,redis} /srv/appdata/mariadb
|
||||||
install -d -o 1000 -g 1000 /srv/appdata/discord-bot /srv/appdata/gitea /srv/appdata/act_runner
|
install -d -o 1000 -g 1000 /srv/appdata/discord-bot /srv/appdata/gitea /srv/appdata/act_runner
|
||||||
|
install -d -o "$OWNER" /srv/live # live portal static build (deployLive.sh)
|
||||||
install -d -o 1000 -g 1000 /srv/media/streams/{techy,diceofire} /srv/media/clips/{techy,diceofire}
|
install -d -o 1000 -g 1000 /srv/media/streams/{techy,diceofire} /srv/media/clips/{techy,diceofire}
|
||||||
for d in /srv/media/clips/*/; do
|
for d in /srv/media/clips/*/; do
|
||||||
[[ -e "$d/.stignore" ]] || install -m 0644 -o 1000 -g 1000 syncthing/stignore "$d/.stignore"
|
[[ -e "$d/.stignore" ]] || install -m 0644 -o 1000 -g 1000 syncthing/stignore "$d/.stignore"
|
||||||
|
|||||||
@@ -56,11 +56,14 @@ export async function publish({ stream, path, user, pass, onState }) {
|
|||||||
const offer = await pc.createOffer()
|
const offer = await pc.createOffer()
|
||||||
await pc.setLocalDescription(offer)
|
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() } } })
|
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)}`
|
// MediaMTX takes WebRTC credentials only in an Authorization header ("Bearer user:pass"). We send them as
|
||||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, credentials: 'same-origin' })
|
// X-Stream-Auth and Caddy rewrites the header on the MediaMTX hop, so Authentik's gate never sees it.
|
||||||
|
const auth = { 'X-Stream-Auth': `Bearer ${user}:${pass}` }
|
||||||
|
const r = await fetch(`/webrtc/${path}/whip`, { method: 'POST', headers: { 'Content-Type': 'application/sdp', ...auth }, 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})`) }
|
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() })
|
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
|
||||||
const session = r.headers.get('Location')
|
let session = r.headers.get('Location') // MediaMTX answers relative to its own root
|
||||||
|
if (session && session.startsWith('/') && !session.startsWith('/webrtc/')) session = '/webrtc' + session
|
||||||
return {
|
return {
|
||||||
pc,
|
pc,
|
||||||
stats: async () => {
|
stats: async () => {
|
||||||
@@ -71,7 +74,7 @@ export async function publish({ stream, path, user, pass, onState }) {
|
|||||||
return { kbps, fps, w, h }
|
return { kbps, fps, w, h }
|
||||||
},
|
},
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
try { if (session) await fetch(session.startsWith('http') ? new URL(session).pathname : session, { method: 'DELETE', credentials: 'same-origin' }) } catch {}
|
try { if (session) await fetch(session.startsWith('http') ? '/webrtc' + new URL(session).pathname : session, { method: 'DELETE', headers: auth, credentials: 'same-origin' }) } catch {}
|
||||||
pc.close()
|
pc.close()
|
||||||
stream.getTracks().forEach(t => t.stop())
|
stream.getTracks().forEach(t => t.stop())
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user