Brandyn / Techy 25e9078d9b Drop the last references to the bot's code living in this repo
switchToClone no longer tries to carry bot/app across a clone, and the compose comment, STATUS row,
README and the plan doc all describe the installed-release model instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 21:58:34 -06:00

vanguard-stream — SYS-1 (Xeon E-2136, 32 GB ECC, 2×4 TB SATA soft RAID)

FRESH-INSTALL.md = the ordered walkthrough from an empty box to a working stack and pipeline, manual steps included. HOSTNAME-CHANGE.md = every place a hostname lives and how to rename a subdomain or move to a new base domain safely. LOGGING.md = log framework: journald + rotated Docker logs, container labels, logfmt events, categories; store (Loki/VictoriaLogs/Netdata) chosen later. UPCOMING.md = what is still missing for a mil-sim community (gap list, techy's take, agreed direction: metadata DB, markers, watch app).

Hostnames (all CNAMEs to the main host; one Discord login covers all of them):

Host What Who
media.BASE (main) gateway page at / (role-aware links + how-tos), /whoami, /logout everyone
files.BASE copyparty: browse, scrub, download, upload finals editors / media-admins
clips.BASE self-service clip intake (pair your Syncthing) any member
sync.BASE Syncthing admin UI media-admins
live.BASE live portal, Go Live, OBS dock /dock/, clock source /dock/clock/, /dump, /me, /time everyone (LIVE_REQUIRE_GROUP)
watch.BASE Jellyfin Discord via OIDC
git.BASE, bot.BASE, auth.BASE Gitea, bot web UI (bot-users), Authentik —

/time (the server clock the timecode strip syncs to) lives only on live because browsers fetch it same-origin: both pages that use it, the clock source and Go Live, are on that host.

STATUS.md = what runs, who does what, what's unfinished (kept current). CommunityDiscordMedia.md = design + phases.

Ingest from OBS, browse/scrub/download for editors, automatic normalization to one editing standard, dump button, 15-day archive pull to a home HDD. Everything is plain files under /srv/media.

1. Reinstall from the OVH manager

Server → Reinstall → Debian 13 (Trixie). Tick custom partitioning and use distribution kernel. Layout (RAID 1 across both HDDs):

mount size fs
/boot 1 GB ext4
/ 250 GB ext4
swap 8 GB per disk swap
/srv rest (~3.4 TiB) xfs

Add your SSH key. OVH's template creates a debian user with sudo; root SSH is disabled. Wait for the mail.

2. Install

scp -r vanguard-stream debian@SERVER:~/
ssh debian@SERVER
cd ~/vanguard-stream && sudo bash install.sh

install.sh installs docker, ffmpeg, rclone; creates /srv/media/{streams,clips,finals,trash,.markers} owned by uid 1000 (the debian user); copies the systemd units; adds debian to the docker group; starts the compose stack. It prints what you still have to edit.

The folder stays where it is: compose bind-mounts the configs and the dock page from it. After install, log out and back in once so the docker group applies, then plain docker compose works from ~/vanguard-stream without sudo.

3. Configure (domain, admin, streamers, editors)

mediamtx.yml, copyparty.conf, Caddyfile, dump-users.txt and .env are generated on the server by configure.py and gitignored. The *.example.* files in the repo are reference only. Never copy them to the server: doing so once replaced the live streamer passwords with CHANGEME and the next apply wiped the streamers. To update the server, copy scripts (configure.py, bin/, docker-compose.yml, …) and re-run ./configure.py.

cd ~/vanguard-stream
./configure.py --full      # first time: asks everything, then applies
./configure.py             # later: add/remove/reset streamers and editors, then apply
./configure.py --show      # print current users and the OBS settings to hand out

Stateless: it reads the current values out of Caddyfile, mediamtx.yml and .env, regenerates Caddyfile, mediamtx.yml, copyparty.conf and streamers.json (→ /etc/vanguard), creates streams/<name> and clips/<name> (with .stignore) for each streamer, restarts dumpd and the containers. Editors and admins are Authentik groups (editors, media-admins, admins), not local accounts. Removing a streamer revokes publishing only; their files stay on disk. Passwords may not contain : & ? # / \ quotes or spaces (they ride inside the stream key).

4. Firewall (host nftables; OVH edge firewall is optional on top)

Applied automatically at the end of install.sh. To re-apply by hand:

cd ~/vanguard-stream && sudo bash firewall/firewall.sh

Installs firewall/nftables.conf as /etc/nftables.conf, syntax-checks, applies, enables at boot. It uses its own table (inet vanguard) so applying or reloading never disturbs Docker's rules, and filters input only so container networking keeps working. Keep your SSH session open and test a new one before closing it.

port proto who
22 tcp anywhere, rate limited (edit ssh_allow in /etc/nftables.conf to restrict to your IPs)
80, 443 tcp world (Caddy)
443 udp world (Caddy HTTP/3)
1935 tcp world (RTMP ingest)
8890 udp world (SRT ingest)
22000 tcp+udp world (Syncthing)
3306 tcp world, rate limited (MariaDB for the bot dev; restrict via db_allow in /etc/nftables.conf)
2222 tcp world (Gitea ssh)
8189 tcp+udp world (WebRTC media for the live portal)

Everything else is dropped and a trickle is logged (journalctl -k | grep vg-drop). Authentik (9000), Jellyfin (8096), copyparty, the Syncthing UI and dumpd bind to 127.0.0.1 only and are reached via Caddy or an ssh tunnel, so they are deliberately not opened. MariaDB runs on the host network (not a Docker published port) precisely so the firewall can govern it. Syncthing's admin UI is proxied at https://sync.BASE/ behind the Authentik gate + group media-admins; its own GUI login is disabled (no user/password, insecureAdminAccess=true) so Discord is the only login:

docker exec syncthing syncthing cli --home /var/syncthing/config config gui insecure-admin-access set true
docker exec syncthing syncthing cli --home /var/syncthing/config config gui user set ""
docker exec syncthing syncthing cli --home /var/syncthing/config config gui password set ""

The ssh tunnel to 8384 still works as an admin fallback (no login at all — ssh is the gate there).

To change rules: edit /etc/nftables.conf, then sudo nft -f /etc/nftables.conf or sudo systemctl reload nftables. Never systemctl stop|restart nftables — Debian's unit flushes the whole ruleset on stop, which also wipes Docker's NAT rules and takes the containers offline until Docker is restarted.

5. Streamer setup (once)

Being a streamer = being in the Authentik streamers group (a Discord role via the roles→groups policy). There are no stream passwords or keys to hand out: the dock mints a 12-hour publish token for the logged-in Discord user each time Go Live is pressed and sets it as the OBS stream key itself.

OBS → Settings → Output → Encoder: B-frames = 0 (NVENC "Max B-frames: 0"; x264 add bframes=0). MediaMTX refuses WebRTC playback of H.264 with B-frames ("WebRTC doesn't support H264 streams with B-frames"), so the live portal would fall back to HLS with seconds of delay. Keyframe interval 2 s. Recording quality is unaffected. OBS → Tools → WebSocket Server Settings → enable, set a password. OBS → Docks → Custom Browser Docks → https://live.BASE/dock/ → log in with Discord once. In the dock's Settings: ingest host (prefilled), OBS WebSocket password/port, optional title prefix/suffix ({date} → YYYY-MM-DD, e.g. prefix HelmetCam- or suffix -{date}). Per stream: type a title, click Go Live. The dock shows the final stream name (<you>/<Title>) as you type and enforces the naming rules the metadata relies on: letters, digits, _ . - only, no double underscore (it separates title and timestamp in filenames), no leading dot, max 64 chars. After a bad night: Dump tonight (dumps the night of the account you are logged in as; admins/media-admins may dump anyone's).

Manual OBS/other software: dock → Manual OBS setup shows the RTMP server, the stream key and the SRT URL with the token hidden (Show / Copy / Fresh token). Tokens expire after 12 h; a running stream is not cut when its token expires, but a reconnect needs a fresh one.

Browser streaming without OBS: the live portal's Go Live (Windows Chrome 141+ for window audio + GPU encode).

5a. Onboarding a gamer's clips (Syncthing) — self-service

Gamers do it themselves at https://clips.BASE/ (Discord login): the page shows our Syncthing Device ID and asks for theirs; on submit, dumpd creates clips/<username> with the video-only .stignore, adds their device to our Syncthing (REST API, key read from /srv/media/.syncthing/config.xml) and creates the receive-only folder clips-<username> shared with them (min free 300 GB). They accept the device + folder on their PC, point it at their clip directory, Send Only. The page shows pairing/connection/sync state. Idempotent. Per-user quota (soft): dumpd measures every clips/<user> every 5 min and pauses that user's Syncthing folder when it exceeds the quota (default CLIPS_QUOTA_GB=100 in dumpd.service), resuming automatically under 90 %. The clips page shows usage and the paused state. Overrides per user in /etc/vanguard/clip-quotas.json, e.g. {"techy2493": 250, "newguy": 50} (GB) — read live, no restart. A gamer frees space by deleting clips in their own folder (Syncthing mirrors the deletion). Hard byte-exact quotas would be XFS project quotas on /srv (needs a remount with prjquota); not set up. Manual fallback (admin, same result for the server folder):

./addClipUser.sh <username>        # creates /srv/media/clips/<username> with the video-only .stignore

Their side (Windows: Syncthing installer or SyncTrayzor; Mac/Linux: Syncthing): open the UI, Actions → Show ID, send you the Device ID. In their UI: Add Remote Device with the server's ID (ours: sync.BASE → Actions → Show ID), then Add Folder on their clip directory (ShadowPlay/Medal/Steam saves), Sharing tab → tick the server, Folder Type Send Only, Save.

Our side at https://sync.BASE/: Add Remote Device → paste their ID, name it after them, Save. When their folder offer pops up: Add → Folder Path /var/syncthing/clips/<username>, Folder Type Receive Only, Advanced → Minimum Free Disk Space 300 GB, Save. Clips start arriving; the sweep normalizes them; Jellyfin shows the folder as that person's show. Their own folder structure is mirrored; only video files are pulled.

5a². Gateway page — https://media.BASE/ (the main host root; /about/ redirects here)

Role-aware landing page (Discord login): everyone sees Watch / Live / Send clips / Sign out with short how-tos; streamers get the dock + OBS setup steps; editors the browser + multicam/timecode notes; media-admins trash/Syncthing/whoami; git-users Gitea; bot-users the bot; admins/authentik Admins the admin consoles and where things live. Identity comes from GET /me (dumpd). Point people here first.

5b. Syncthing hardening (do once, in the server's Syncthing UI)

Syncthing receives straight into clips/<user> (Syncthing UI path /var/syncthing/clips/<user>), receive-only, with a .stignore (from syncthing/stignore) that pulls video files only. The gamer's folder structure is mirrored as-is. One copy per clip on the server: compliant files are left alone; a non-compliant file (HEVC/MKV/VFR) is converted to name.mp4 (name.h264.mp4 if the raw was already .mp4), the raw's name is appended to .stlocal (an #included ignore list, so Syncthing drops it without flagging or re-pulling), and the raw is deleted locally — it still exists on the gamer's PC. configure.py creates the folder + ignore file for each streamer.

In the UI (https://sync.BASE/):

  • each clips folder → path /var/syncthing/clips/<user>, Folder Type: Receive Only, Edit → Advanced → Minimum Free Disk Space: 300 GB (so clips can never fill the mirror and stall the recordings).
  • Actions → Settings → Connections → Incoming Rate Limit: 12500 KiB/s (~100 Mbps) so a gamer sharing a year of old clips cannot starve the ingest during stream hours.

5c. Authentik (Discord login) and Jellyfin

DNS: auth, watch (and files, clips, sync, live, git, bot) as CNAMEs to the main host. Then run ./addCommunityMediaServer.sh — it asks for every hostname, checks DNS, generates .env (Authentik secret + DB password; never commit it), adds the Caddy site blocks, starts the containers, waits for them and prints the steps below with your hostnames filled in. (./configure.py --full asks for the same hostnames if you prefer.) Then:

Authentik first run — https://auth.HOST/if/flow/initial-setup/ → set the akadmin password.

  1. Discord developer portal → New Application → OAuth2: add redirect https://auth.HOST/source/oauth/callback/discord/ (last segment = the source slug below), copy Client ID + Secret.
  2. Authentik → Directory → Federation & Social login → Create → Discord OAuth Source: slug discord, consumer key/secret = Client ID/Secret, user matching "link on unique identifier", scopes empty.
  3. Flows & Stages → Stages → default-authentication-identification → Sources: select Discord, tick "Show sources' labels". Without this the Discord button never appears on the login page.
  4. Flows & Stages → Stages → default-source-enrollment-write → User type: Internal. Otherwise Discord users are External and get "Interface can only be accessed by internal users".
  5. Directory → Groups: create streamers, editors, admins. Add people after their first login.
  6. (optional) Restrict login to members of your Discord server: add scope guilds to the source; Customization → Policies → Create Expression Policy from authentik/discord-guild-policy.py (fill in your server ID); bind it to both default-source-enrollment and default-source-authentication (flow → Policy / Group / User Bindings) and set each flow's Policy engine mode to all. Server ID: Discord Developer Mode → right-click server → Copy ID.

Jellyfin first run — https://watch.HOST/ → wizard: admin user, add libraries: /media/streams and /media/clips as Shows with only "Vanguard Metadata" as metadata downloader (Series/Season/Episode) and Screen Grabber + Vanguard Metadata as image fetchers; /media/finals as Home Videos with fetchers off. The Vanguard Metadata plugin (jellyfin-plugin-vanguard/, built with its package.sh) is installed by addCommunityMediaServer.sh / installJellyfinPlugin.sh from jellyfin-plugin-vanguard/dist/.

  • Dashboard → Playback → Transcoding: disable hardware acceleration. Per-user "allow transcoding" is switched off automatically for everyone (SSO users included) by the Vanguard plugin's "enforce playback policy" task; turn that toggle off in the plugin config once the server has Quick Sync (SYS-3).
  • Dashboard → Plugins → Repositories → add https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json, then Catalog → SSO Authentication → install → restart Jellyfin.
  • Authentik → Applications → Create with provider → OAuth2/OpenID: redirect https://watch.HOST/sso/OID/redirect/authentik, note client ID/secret.
  • Jellyfin → Plugins → SSO-Auth → add OID provider "authentik": endpoint https://auth.HOST/application/o/<slug>/, client ID/secret, enable, roles claim groups, admin roles admins, enabled folders = the three libraries, Scheme Override https, Port Override empty (a value appends :443 and Authentik rejects the redirect). Save.
  • The plugin adds no button. Test with https://watch.HOST/sso/OID/start/authentik, then Dashboard → General → Branding: Login disclaimer = jellyfin/branding-disclaimer.html (set the hostname), Custom CSS = jellyfin/branding.css. The page then shows only "Sign in with Discord"; a small "Admin login" link reveals the local username/password form. Jellyfin creates the account on first SSO login.

Phase 4 is the Vanguard Metadata Jellyfin plugin; Phase 5 puts copyparty, /sync and /dump behind Authentik forward-auth.

5d. MariaDB + Discord bot (for the bot dev)

mariadb (public on 3306, host network, root local-only, 32-char generated passwords) with a database/user for the bot, and discord-bot, a Python 3.12 container that runs /app/main.py and idles until a release is installed. Credentials live in .env (generated by configure.py; bot token + guild ID are asked in --full or the [d] menu item; ./configure.py --show prints them).

Releases are self-contained bundles, and a push to main deploys itself. The bot repo's Gitea Actions workflow runs its tests and then builds one tar.gz holding main.py, app/, website/, BUILD.json and vendor/ — its own dependencies, as prebuilt wheels for this image's Python. PYTHONPATH=/app/vendor is how the code finds them, so a new dependency never needs an image rebuild and the server never contacts a package index.

The workflow's last job posts that bundle to dumpd at POST /deploy-bot on 8081 — the Docker-bridge-only port the firewall already restricts, so a CI job never needs the Docker socket or an ssh key. dumpd checks an HMAC-SHA256 signature over the body, then runs /usr/local/bin/vanguard-deploy-bot (this repo's deployBotBundle.sh), which verifies every file against BUILD.json, swaps the bundle into /srv/bot/app (bind-mounted at /app, previous release kept beside it) and restarts the container. The install log goes back in the HTTP response, so it appears in the CI job.

The bot's own secrets live in Gitea, not here. The deploy job also sends the bot's settings, signed with the bundle, and dumpd writes them into this stack's .env — but only the keys BOT_ENV_KEYS lists. Anything else comes back to the CI job as settings_refused and is not written, so the database password and the Authentik keys this repo generates cannot be overwritten from CI. Rotating the Discord token is therefore a Gitea edit plus a re-run, with no server access.

In Gitea, on the bot repo under Settings → Actions:

Secret BOT_DEPLOY_SECRET must equal the server's; ./configure.py --show prints it
Secret DISCORD_BOT_TOKEN the bot token
Variable DISCORD_GUILD_ID the server id
Variable VANGUARD_DEPLOY_HOST only if the job cannot detect the Docker gateway

On the server, generated into .env and copied to /etc/vanguard/deploy.env by configure.py:

BOT_DEPLOY_SECRET 64 hex chars, the shared signing key
BOT_DEPLOY_AUTO 1 (default) accepts CI installs; 0 makes dumpd refuse them with 403
BOT_ENV_KEYS the only .env keys CI may set, DISCORD_BOT_TOKEN,DISCORD_GUILD_ID by default. Extend it when the bot needs a new setting.

By hand, any time:

./deployBotBundle.sh vanguard-bot-bundle.zip      # install a release (the artifact as Gitea hands it over, zip or tar.gz)
./deployBotBundle.sh --latest                     # fetch the newest main build first (needs BOT_REPO_TOKEN in .env)
./deployBotBundle.sh --status                     # version, commit, package list, container state
./deployBotBundle.sh --rollback                   # back to the previous bundle
docker compose logs -f discord-bot                # watch it

The bot serves both its API and its web pages on 127.0.0.1:8000, published as https://bot.HOST/ behind Authentik forward-auth: Caddy asks Authentik, then injects X-Authentik-Username/-Groups/-Email, and the bot authorises per request from those headers (WEB_ALLOWED_GROUPS=bot-users,admins; WEB_SERVE_UI=true turns the pages on). Create the matching Proxy Provider in Authentik (forward auth, single application, external host https://bot.HOST), add it to the embedded outpost, and bind a group policy. Until then the site returns an error, never an open page. Caddy's own gate is BOT_REQUIRE_GROUP (bot-users by default), so admins need the bot-users role too — or set BOT_REQUIRE_GROUP= and let the bot's group list decide.

The build brief for whoever writes the bot is bot/AGENT.md; bot/README.md is the ops cheat-sheet.

5e. Gitea + Actions runner

gitea (MariaDB-backed, git.HOST via Caddy, git-over-ssh on 2222, registration closed) and act_runner (jobs run as Docker containers; labels ubuntu-latest, ubuntu-22.04, dotnet, python). The gitea db/user are created by mariadb/init/10-gitea.sh on MariaDB's first start; for a MariaDB that already existed run ./createGiteaDb.sh once.

  1. ./configure.py --full asks the Gitea hostname (CNAME git); apply starts both containers.
  2. https://git.HOST/ → install page (db pre-filled) → create the admin account → Install.
  3. ./registerGiteaRunner.sh → generates a registration token, stores it in .env, starts the runner.
  4. Remotes: ssh://git@git.HOST:2222/<org>/<repo>.git or https://git.HOST/<org>/<repo>.git.

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.

Login via Authentik (Discord) — same pattern as Jellyfin, done once:

  1. Authentik → Applications → Create with provider → name Gitea, slug gitea; OAuth2/OpenID Provider, authorization flow implicit-consent, client type Confidential, redirect URI (strict) https://git.HOST/user/oauth2/authentik/callback, signing key = the default certificate, scopes openid email profile (the default profile mapping carries preferred_username and groups). Copy client ID + secret.

  2. Gitea → Site Administration → Identity & Access → Authentication Sources → Add: type OAuth2, name authentik (must match the callback path), provider OpenID Connect, client ID/secret, OpenID Connect Auto Discovery URL https://auth.HOST/application/o/gitea/.well-known/openid-configuration, tick "Skip local 2FA", claim name for groups groups, administrator group claim value git-admins. Save. Authentik group nesting makes the admins inherit it: authentik Admins → parent admins → parent git-admins (members of a child group count as members of the parent). Inherited groups are not in the groups claim by default: the managed profile mapping uses ak_groups (direct memberships only), and proxy providers re-add that mapping on every save, so it cannot be swapped out. Add a second scope mapping, Customization → Property Mappings → Create → Scope Mapping, name Vanguard profile (nested groups), scope profile, expression:

    direct = set(request.user.ak_groups.all())
    return {"groups": sorted(g.name for g in request.user.all_groups() if g not in direct)}
    

    and tick it (next to the default one) on the Vanguard sites, Jellyfin and Gitea providers → Advanced protocol settings → Scopes. Claims from several mappings merge, so the header becomes direct + inherited groups without duplicates. Takes effect on the next login.

  3. compose (already set): registration only via external sources, auto-registration + account linking on, username from preferred_username. A first Discord login creates the Gitea user with no form.

  4. Access = Authentik group git-users: Directory → Groups → create it (add a Discord role to it in the roles→groups table); Applications → Gitea → Policy / Group / User Bindings → bind git-users and git-admins. Non-members are refused by Authentik before Gitea sees them.

  5. Test in a private window: https://git.HOST/user/login → "Sign in with authentik" → Discord → logged in. Members of admins become Gitea site admins on login. The local admin account stays as a fallback. Optional later: hide the password form (custom template) so Discord is the only visible way in.

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 for the on-ready hook bin/mtx-on-ready.sh (bind-mounted into the container): the optional "is live" Discord post (LIVE_WEBHOOK_URL in .env, asked by configure.py --full) and the WebRTC audio mirror. Browsers cannot receive AAC over WebRTC (MediaMTX forwards Opus/G711/G722/LPCM only) and OBS sends AAC, so for every stream whose audio is not already Opus the hook runs one ffmpeg that copies the video and transcodes the audio to Opus into the sibling path <user>/<Title>.webrtc (via MediaMTX's RTSP on the container loopback). The portal plays the mirror over WebRTC and hides it as a panel; recordings, HLS and the Discord post stay on the original. Mirrors are never recorded and can only be published from inside the container (dumpd checks the source IP). Browser Go Live already sends Opus and gets no mirror. Cost: a few % of a core per stream. Viewer requirement on the streamer side: B-frames 0 (section 5), or WebRTC is refused and the portal falls back to HLS.

  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 (window audio: Windows + Chrome/Edge 141+; Linux/Mac get video only): broadcast a window from the browser (no OBS) as <user>/HelmetCam-<date> — a fresh publish token comes from /me/stream-token for the logged-in Discord user (member of streamers).

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.

5g. Authentik forward-auth for the Caddy-gated hosts (media., files., clips., sync., live., bot.)

Design: Authentik authenticates once for the whole domain; Caddy authorizes per host from the X-Authentik-Groups header the outpost returns; Discord roles fill the Authentik groups at login.

  1. Applications → Outposts → edit "authentik Embedded Outpost" → config authentik_host: https://auth.HOST.
  2. Applications → Create with provider → Application Vanguard sites; Proxy Provider, authorization flow implicit-consent, mode Forward auth (domain level), authentication URL https://auth.HOST, cookie domain = the parent domain (e.g. dumpstervigilantes.rip). Edit the Embedded Outpost → Applications → add it → Update. One login now covers every gated host.
  3. Per-host group requirement = .env: BOT_REQUIRE_GROUP=bot-users (default), LIVE_REQUIRE_GROUP= (empty = any logged-in member), SYNC_REQUIRE_GROUP=media-admins (the Syncthing UI on sync.). On files. copyparty itself authorizes by group (editors, media-admins); the dump endpoint lets admins/media-admins dump anyone's night. Leave "Intercept header authentication" off: login is Discord only, no app passwords, no WebDAV. ./configure.py apply regenerates the Caddy blocks; a user outside the group gets a 403 from Caddy before the app sees the request.
  4. Discord roles → groups: Directory → Groups: create bot-users (and 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 (fill in the server ID and the role-id → group table). Bind it to default-source-enrollment and default-source-authentication (engine mode all, after discord-guild-member). From then on giving someone a Discord role gives them the matching access on next login; removing it removes it. Memberships added by hand in Authentik are left alone: the policy only revokes what it granted itself (tracked in the user attribute discord_role_groups). copyparty additionally requires a header with a secret name that only Caddy adds (idp-h-key, COPYPARTY_IDP_KEY in .env), so identity headers cannot be forged even if copyparty were reached directly. Logout: copyparty's logout link, the live portal's "Sign out" and the dock's "sign out" go through /outpost.goauthentik.io/sign_out; https://<any gated host>/logout does the same from anywhere (e.g. from the Syncthing UI, which has no logout of its own). Set the provider's Invalidation flow to default-provider-invalidation-flow so that also ends the Authentik login: every logout then forces a full Discord re-login on the next visit (and picks up group changes). Discord itself may approve silently if you are still logged into Discord in that browser.
  5. Debug: https://HOST/whoami prints the username/groups headers exactly as the apps receive them. Group changes in Authentik apply on the next login — log out at https://auth.HOST/ and back in.
  6. Test in a private window: https://live.HOST/ → Discord login → portal; https://bot.HOST/ → 403 unless you hold the bot role; https://live.HOST/me/stream-key shows your username.

Jellyfin and Gitea use OIDC providers instead, not this.

6. Editors

Log in with Discord at https://files.BASE/ (copyparty behind Caddy + Authentik). Access is by Authentik group: editors read streams and clips and write /finals; media-admins have full rights everywhere including /trash. Scrub any .mp4 in the browser, download what you want.

No WebDAV: login is Discord only (no passwords of any kind), and a mounted drive cannot do a browser login. Download from the browser: single files, or select files/folders and use copyparty's zip/tar download.

Available but not set up. WebDAV mounts can be enabled later without code changes if we decide we want them: in Authentik, edit the Vanguard sites proxy provider and tick Intercept header authentication; each editor then creates an app password under their Authentik user settings and mounts the same URL with their Authentik username + that app password. Caddy and copyparty already pass the identity through, so nothing else changes. It is off because it introduces a password-like credential alongside Discord login.

Every .mp4 an editor sees is the same kind of file (see "Editing standard"). Raw clipper output that needed converting is kept in a hidden .src/ folder next to it (media-admins can show dotfiles in copyparty).

7. Home archive box

Copy home/rclone-archive.sh to the home machine, set the vars, run from cron nightly. It connects over SFTP as debian (all media files are owned by uid 1000 so it can delete them). Pulls everything older than 15 days off the server, including the hidden .src/ raw files.

Publish authentication (no passwords)

MediaMTX asks dumpd (POST /mtx-auth on 127.0.0.1:8081, reachable only from Docker's bridge) for every connection. Reads/playback are allowed (viewers are gated by Caddy + Authentik). Publishing needs a token minted by GET /me/stream-token behind Authentik for a member of streamers/admins/media-admins; dumpd checks the HMAC signature (/etc/vanguard/stream-token.secret, generated into .env as STREAM_TOKEN_SECRET), the expiry, and that the path starts with that user's name. MediaMTX does not pick up mediamtx.yml changes live on this setup: after any change to it, docker compose restart mediamtx between streams. Day-to-day operations (streamers, tokens, titles) never touch that file. configure.py reloads Caddy in place and restarts only copyparty.

Timecode for multicam editing (frame accurate)

Every published stream carries a QuickTime timecode track (tmcd, time of day in Eastern, at the file's frame rate) and a UTC creation_time, written by the sweep when it publishes the file, plus a sidecar <Title>__<ts>.json with the exact capture time (the Jellyfin plugin reads it). Resolve and Premiere read the track natively: drop clips from several streamers on a multicam timeline, sync by timecode.

Where the time comes from, best first:

  1. Overscan band — a thin strip of extra rows below the picture carrying the server clock, encoded (black/white cells, format in bin/vgstrip.py) plus a small readable clock. The server decodes it (bin/vgstrip.py) at the start, middle and end of each recording → the timecode is frame-accurate at the start and the sidecar records drift over the file. Then the band is cropped off: the sweep re-encodes the stream (libx264 CRF 18, frame timing preserved, audio copied) so the published file is the game image + audio + the tmcd track and nothing else; the raw with the band is deleted (KEEP_RAW_STREAMS=1 keeps it in .src/). Streams without the band are remuxed losslessly. Cost: every re-encode (streams and clips) runs x264 on ENCODE_THREADS threads (2, set in systemd/normalize-sweep.service) so Jellyfin and live viewers stay responsive; a 3 h 1440p60 night therefore takes most of the next day to publish. OBS: dock → Timecode band → "Add band + clock source" (canvas grows by W/160 rows, a locked browser source Vanguard Clock → https://live.BASE/dock/clock/ sits in the band; only while not streaming). Go Live: the "Timecode band" checkbox (default on).
  2. Server arrival time (no band) — microseconds in the recording filename. Within ~0.1–0.3 s per streamer (encoder + network delay); coarse but always available.

Viewing: the live portal crops the band on the live picture (the only place it still exists); recordings in Jellyfin, copyparty and the editors' timelines are already at game resolution. Recordings published before the crop existed: sudo vgdeband.sh [FILE…] re-encodes them the same way and marks the sidecar. Cross-check when it matters: everyone is in the same voice chat, so audio-waveform sync is sample-accurate. Why continuous and not a start burst: a PC clock drifts vs the server (10 ppm ≈ 4 frames over 2 h).

Dropped frames / gaps: OBS keeps stamping frames from its clock, so after a drop the file has a hole and every frame that exists is still at the right time — the timecode stays correct, and players (Jellyfin, the portal) hold the last frame across the hole with audio in sync. By default the sweep does not re-encode a stream to fill holes (hours of CPU on this box); the sidecar lists them (gaps.count, total_missing_ms, first 50 positions). Premiere preserves timing on such files; in Resolve check the clip's frame-rate interpretation. Gap filling (available, off by default). The sweep can re-encode a stream whose holes total at least FILL_GAPS_MS milliseconds to constant frame rate with duplicated frames (raw kept in .src/, timecode carried over). It costs ~2× the stream length in CPU on the E-2136, so it is off until the box has a hardware encoder. Turn it on (threshold 1 s) / off, on the server:

sudo sed -i 's|^#* *Environment=FILL_GAPS_MS=.*|Environment=FILL_GAPS_MS=1000|' /etc/systemd/system/normalize-sweep.service && sudo systemctl daemon-reload
sudo sed -i 's|^Environment=FILL_GAPS_MS=.*|# Environment=FILL_GAPS_MS=1000|' /etc/systemd/system/normalize-sweep.service && sudo systemctl daemon-reload

Check: systemctl show normalize-sweep -p Environment. Takes effect on the next sweep run; already-published files are not revisited. Clips are always converted to CFR regardless. Planned alternative: re-encode flagged streams off-site on a machine with a hardware encoder (home box NVENC or a Spot GPU) and push the result back — see CommunityDiscordMedia.md → Later.

Deployment (push to deploy — built, OFF by default)

The server folder is a git clone of this repo (./switchToClone.sh key → add the printed deploy key in Gitea → ./switchToClone.sh clone). Generated files (.env, Caddyfile, mediamtx.yml, copyparty.conf, the token secret) are gitignored and survive pulls; static pages are bind-mounted from the checkout.

sudo vanguard-deploy pull fetches main and applies only what changed, after check.sh:

changed action interruption
configure.py, dock/, clips/, about/, stignore configure.py --apply → Caddy reload, copyparty restart none / a second
bin/*, systemd/* install; dumpd restart when it changed; timers pick up next run a second
firewall/nftables.conf nft -f none
docker-compose.yml, bot/Dockerfile, bot/entrypoint.sh compose up -d --build for changed services that service
deployBotBundle.sh reinstalled as /usr/local/bin/vanguard-deploy-bot (what dumpd runs for the bot's CI) none
anything that changes mediamtx.yml or the mediamtx service restart only when no stream is live; otherwise deferred, deploy-pending.timer retries every 5 min none while live
jellyfin-plugin-vanguard/dist/*.zip (committed) installJellyfinPlugin.sh Jellyfin restart
live/ npm run build on the server → /srv/live none

sudo vanguard-deploy rollback checks out the previously deployed revision and applies the reverse diff. sudo vanguard-deploy status shows revisions, auto mode, pending restarts, live streams.

One-time rollout (the last scp-style deploy; afterwards the server pulls). From the dev box:

cd ~/claudevs/vanguard-stream && git push
scp bin/dumpd.py debian@HOST:~/vanguard-stream/bin/
scp systemd/deploy-pending.service systemd/deploy-pending.timer debian@HOST:~/vanguard-stream/systemd/
scp docker-compose.yml configure.py deploy.sh switchToClone.sh check.sh install.sh README.md STATUS.md CommunityDiscordMedia.md debian@HOST:~/vanguard-stream/
ssh -t debian@HOST 'cd ~/vanguard-stream && sudo apt-get install -y -q git nodejs npm rsync python3-yaml >/dev/null && sudo install -m 0755 bin/dumpd.py /usr/local/bin/ && ./configure.py --apply && sudo systemctl restart dumpd && sudo vanguard-deploy status'

The apply prints the post-start fixups (Syncthing, Gitea admin user, Jellyfin plugin) and ends with a status block: DEPLOY_AUTO=0, no pending restart, and "not a git checkout" until the next step.

Then switch the server folder to a clone (two steps):

ssh -t debian@HOST 'cd ~/vanguard-stream && ./switchToClone.sh key'       # prints a public key
# Gitea → repo → Settings → Deploy Keys → add it, read-only
ssh -t debian@HOST 'cd ~/vanguard-stream && ./switchToClone.sh clone && cd ~/vanguard-stream && ./configure.py --apply >/dev/null && sudo vanguard-deploy status'

switchToClone.sh clone keeps .env, the generated configs, the token secret and plugin zips, and leaves the old copy at ~/vanguard-stream.old until you delete it. From here on a manual deploy is sudo vanguard-deploy pull on the server.

Automatic: Gitea → repo → Settings → Webhooks → Gitea, URL http://<docker gateway>:8081/deploy (docker network inspect vanguard-stream_default -f '{{(index .IPAM.Config 0).Gateway}}', usually 172.18.0.1), secret = DEPLOY_WEBHOOK_SECRET from .env, push events, branch main. dumpd verifies the HMAC and runs vanguard-deploy pull only when DEPLOY_AUTO=1 in .env (then ./configure.py --apply to push the setting to /etc/vanguard/deploy.env). Until then every push is logged (journalctl -u dumpd | grep deploy) and ignored; Gitea's "test delivery" should get a 202 "auto-deploy disabled" back. Optional DEPLOY_NOTIFY_WEBHOOK_URL posts deploy results to Discord. Turning it on: DEPLOY_AUTO=1 in .env, ./configure.py --apply, and git mv .gitea/draft-workflows/ci.yml .gitea/workflows/ci.yml for CI.

CI: .gitea/draft-workflows/ci.yml (inert until moved to .gitea/workflows/) runs check.sh and the plugin tests/build on the Actions runner. The runner never deploys — it must not touch the host.

Rebuild from scratch (what is and isn't automated)

Server side, scripted:

git clone ssh://git@git.HOST:2222/techy/Media-Server-Setup.git ~/vanguard-stream   # or scp the folder
cd ~/vanguard-stream && sudo bash install.sh        # packages, docker, folders, host scripts, timers, firewall, portal build
./configure.py --full                               # hostnames, secrets, configs, containers, post-start fixups

Restore first if rebuilding a known box: .env (all secrets — without it every password is regenerated and the databases won't match), /srv/appdata (Authentik DB, Jellyfin, Gitea, MariaDB, Syncthing config), /srv/media (the footage), /etc/vanguard/clip-quotas.json if used. With those restored, nothing below is needed except DNS.

Post-start fixups configure.py performs on every apply (idempotent): Syncthing GUI auth off, Gitea admin user created from .env (GITEA_ADMIN_USER / GITEA_ADMIN_PASSWORD; the install page is locked), Jellyfin plugin installed from the committed zip. Authentik's akadmin password is set on first start from AUTHENTIK_BOOTSTRAP_PASSWORD in .env (no initial-setup page).

Still manual on a truly fresh box (not in the repo; documented in §5c–5g):

  • Authentik: Discord source, identification-stage source, enrollment user type, groups, the two policies, the domain-level proxy provider + outpost host, the Jellyfin and Gitea OIDC providers, application bindings
  • Jellyfin: first-run wizard, libraries (Shows + Vanguard provider), SSO plugin + provider, branding
  • Gitea: authentication source (authentik), deploy key, webhook; ./registerGiteaRunner.sh
  • Discord bot: its repo lives outside this one, so nothing here restores it. Put BOT_DEPLOY_SECRET (./configure.py --show), DISCORD_BOT_TOKEN and DISCORD_GUILD_ID into that repo's Gitea Actions secrets/variables, then push to its main — or install a bundle by hand with ./deployBotBundle.sh. /srv/bot/app is created empty by install.sh; the container idles until a release lands there.
  • Discord developer portal: application, redirect URIs, bot; DNS records A .env-driven Authentik blueprint would remove the first line (planned).

Layout on the server

/srv/media/streams/<user>/<Title>/.live/<ts>.mp4             recording in progress (hidden from Jellyfin/copyparty)
/srv/media/streams/<user>/<Title>__<ts>.json                 capture time (UTC ms), timecode, source (strip|arrival), drift
/srv/media/streams/<user>/<Title>__<ts>.mp4                  finished stream, one flat MP4 per recording, directly in
                                                             the streamer's folder (several files inside one title
                                                             folder would be ONE Jellyfin episode with "versions")
/srv/media/clips/<user>/clip.mp4                             clip (synced as-is, or converted; raw removed)
/srv/media/clips/<user>/.stlocal                             Syncthing ignore list maintained by the sweep
/srv/media/clips/<user>/.src/clip.mkv                        raw source, only for clips that did NOT arrive via Syncthing
/srv/media/clips/<user>/clip.normalize-failed.log            only if normalization failed; delete to retry
/srv/bot/app/                                                the Discord bot's installed release (bind-mounted at /app)
/srv/bot/previous/                                           the release before it (deployBotBundle.sh --rollback)
/srv/media/finals/                                           editors upload here
/srv/media/trash/<night>/...                                 dumped, purged after 3 days
/srv/media/.markers/                                         dump markers
/srv/media/.syncthing/                                       Syncthing config

Editing standard (what normalize-sweep enforces)

MP4 · H.264 High, 8-bit 4:2:0 · constant frame rate · AAC audio (all tracks kept) · index at the front (instant seek in browser and NLE) · at most 2560x1440 and 60 fps (downscaled to fit, never upscaled; aspect kept).

input action
already compliant nothing
MediaMTX recording in <Title>/.live/ (fragmented MP4) once idle 3 min: decode the timecode band (or use arrival time), then publish to <user>/<Title>__<ts>.mp4 with a tmcd track + creation_time — band present: re-encode with the band cropped off; no band: lossless remux — write the .json sidecar, remove .live and the empty title folder
in a Syncthing folder (.stfolder marker) compliant / index-only: untouched; otherwise convert → name.mp4, add raw to .stlocal, delete raw (one copy)
HEVC / 10-bit / VFR / MKV / >1440p / >60fps / non-AAC full-res re-encode (x264 veryfast CRF 18) → name.mp4; source kept in .src/
ffmpeg fails or duration mismatch name.normalize-failed.log written, file skipped until that log is deleted

Runs every 5 min on files idle ≥3 min and not open. Software x264 on the E-2136 does ~0.5x realtime at 1440p60, so a 2 h clip batch takes ~4 h; streams only remux (minutes). All at idle priority.

Reboot behaviour

Everything comes back on its own, in this order:

  1. mdadm assembles the mirror, systemd mounts /srv (local-fs). A degraded mirror still boots.
  2. docker.service has RequiresMountsFor=/srv/media (drop-in installed by install.sh), so it never starts with the media tree missing. All containers are restart: unless-stopped.
  3. dumpd.service starts; normalize-sweep.timer fires 2 min after boot and every 5 min after; trash-purge.timer is Persistent=true so a missed 05:00 run fires at boot.
  4. A stream cut by a reboot is playable up to the last second (fMP4 parts are flushed every 1 s); OBS reconnects and MediaMTX opens a new file in the same title folder.
  5. Half-written *.part files are overwritten by the next sweep. Dumps are idempotent.

Time: install.sh sets the server clock to America/New_York so journal, timers, file timestamps and the dump window all read in Eastern. Dump "night" = 17:00 → 02:00 Eastern (pinned in dumpd via DUMP_TZ, independent of the host zone). Between 02:00 and 17:00 the default night is the previous evening; after 17:00 it is tonight. The 05:00 trash purge is 05:00 Eastern.

Timers

unit when does
normalize-sweep.timer every 5 min remux/re-encode finished files to the editing standard
trash-purge.timer daily 05:00 Eastern deletes trash older than 3 days
dumpd.service always the dump endpoint on 127.0.0.1:8080, behind Caddy on live. at /dump, /me, /time and on clips. at /me/clips
Description
No description provided
Readme 2.3 MiB
Languages
Python 42.8%
Shell 19.2%
HTML 16.2%
JavaScript 10.1%
C# 9.8%
Other 1.9%