Files
Media-Server-Setup/CommunityDiscordMedia.md
Brandyn / Techy 3662eb3465 Phase 14: diag. host with VictoriaLogs at /logs, ops controls later
Records the store decision (VictoriaLogs over ELK and over Grafana+Loki, with the reasons), the hostname and
path, the collector choice, and the one real decision left for build time: how Vector reads container logs
without handing it root through the Docker socket. Also a design note for the later ops controls, so restart
buttons end up as allowlisted dumpd endpoints rather than a web page with Docker access.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 15:33:47 -06:00

34 KiB
Raw Blame History

Community Discord Media — plan

Current state lives in STATUS.md (what runs, who does what, what's unfinished). This file is the design and the phase checklists.

One login for everything (Discord via Authentik), a watch page (Jellyfin), and the existing ingest/browse/download stack (MediaMTX, copyparty, Syncthing) moved behind that login.

Host: OVH SYS-1 (Xeon E-2136, 32 GB, 2×4 TB mirror), Debian 13, Docker Compose, Caddy TLS. Bundle: vanguard-stream/ in this repo; live copy at ~/vanguard-stream on the server.

Where we are

piece state
MediaMTX ingest (RTMP 1935 / SRT 8890), per-streamer keys live
copyparty browse/scrub/download/WebDAV at https://ovh1.dumpstervigilantes.rip/ live, local accounts
OBS dock (/dock/), dump-tonight (/dump) live, dump uses its own password file
Syncthing clips intake (22000), admin UI at /sync/ live, Syncthing's own GUI login
normalize-sweep (one editing standard, raw files in hidden .src/) live
nftables host firewall live
Authentik (Discord login, groups) at auth. live
Jellyfin at watch., SSO via Authentik, direct play only live
Vanguard Metadata plugin v0.1 installed, libraries being switched to Shows
MariaDB (bot database) + discord-bot Python container (release installed in /srv/bot/app) live; first release pending
Gitea (git., MariaDB, ssh 2222) + Actions runner live (local admin account; Authentik OIDC → Phase 10)

Decisions already made

  • Discord is the identity. Authentik is the only self-hosted IdP that does Discord social login, groups, OIDC and forward-auth for apps with no login of their own. Keycloak/Authelia/Pocket-ID don't fit.
  • Groups: streamers, editors, admins. Access everywhere is by group, not by person.
  • Jellyfin is direct-play only until a SYS-3 (Xeon E-2288G, Quick Sync). The E-2136 has no hardware encoder; software transcoding tops out at ~2 concurrent viewers. Transcoding is off at the server and per user.
  • MediaMTX stream keys stay machine credentials. OBS can't do a Discord login. Keys are managed by configure.py; the dock will fetch the logged-in streamer's key after Authentik login (phase 6).
  • Raw clipper files live in hidden .src/ folders so Jellyfin and copyparty show one item per clip.
  • Hostnames: auth. and watch. are CNAMEs to the main host. Caddy routes by SNI, Let's Encrypt follows CNAMEs. One A record to change if the IP ever moves.
  • Discord login only, no passwords anywhere → no WebDAV (a mounted drive can't do a browser login). Editors download from copyparty's web UI (zip/tar for folders). WebDAV stays available (Authentik "Intercept header authentication" + per-user app passwords, no code change) if we ever choose it.

Target layout

auth.dumpstervigilantes.rip   Authentik  — Discord login, groups, OIDC provider, forward-auth gate
watch.dumpstervigilantes.rip  Jellyfin   — libraries: streams / clips / finals, SSO plugin → Authentik
ovh1.dumpstervigilantes.rip   Caddy
   /            copyparty   (forward-auth → Authentik; user+groups via headers; perms by @group)
   /dock/       OBS dock    (requires Authentik session; shows the caller's own stream key)
   /dump        dumpd       (trusts X-Authentik-Username; admins may dump anyone)
   /sync/       Syncthing   (forward-auth, admins only)
rtmp://ovh1:1935  srt://ovh1:8890   MediaMTX (unchanged; per-streamer user/pass in stream key)

Phases

Phase 1 — DNS + Authentik/Jellyfin containers ✅ done → addCommunityMediaServer.sh

  • CNAME auth → ovh1.dumpstervigilantes.rip
  • CNAME watch → ovh1.dumpstervigilantes.rip
  • from your machine: scp -r bin docker-compose.yml configure.py install.sh addCommunityMediaServer.sh README.md CommunityDiscordMedia.md .env.example debian@ovh1…:~/vanguard-stream/
  • on the server: cd ~/vanguard-stream && sudo install -m 0755 bin/normalize-sweep.sh /usr/local/bin/ && ./addCommunityMediaServer.sh — asks for the two hostnames (defaults derived from the main host), checks they resolve to this box, writes Caddy blocks + .env (secrets) via configure.py --hosts, starts the containers, waits for HTTPS on both names, then prints the Phase 2/3 steps with the real hostnames. Re-runnable; remembers the hostnames.
  • docker compose ps → 9 containers up; free -h sane

Phase 2 — Authentik + Discord ✅ done (loose ends → Phase 8)

  • https://auth.HOST/if/flow/initial-setup/ → set akadmin password (store it somewhere safe)
  • Discord developer portal → application → OAuth2 redirect https://auth.HOST/source/oauth/callback/<slug>/ (slug = the Authentik source slug, e.g. discord; they must match exactly)
  • Authentik → Directory → Federation & Social login → Create → Discord OAuth Source (slug discord; consumer key/secret = Discord client id/secret; user matching "link on unique identifier" is fine; scopes empty; flows default-source-authentication / default-source-enrollment)
  • rotate the Discord client secret after setup if it was ever pasted into a chat/screenshot
  • Show the source on the login page: Flows & Stages → Stages → default-authentication-identification → Sources: select Discord, tick "Show sources' labels" (new sources are hidden until this is done)
  • Make enrolled users Internal: Flows & Stages → Stages → default-source-enrollment-write → User type: Internal (External users get "Interface can only be accessed by internal users")
  • Directory → Groups: streamers, editors, admins
  • Log in with Discord as a normal user; confirm the account is created and Internal; add yourself to admins
  • only members of our Discord server may log in — authentik/discord-guild-policy.py: 1. Discord Developer Mode → right-click server → Copy Server ID 2. Discord source → Scopes: guilds 3. Customization → Policies → Create Expression Policy discord-guild-member, paste the file, set ACCEPTED_GUILD_ID / GUILD_NAME 4. bind it to default-source-enrollment and default-source-authentication (flow → Policy / Group / User Bindings → Bind existing policy), then edit both flows → Policy engine mode all (with any, the existing if-sso binding passes on its own) 5. test with a member and a non-member; existing users re-consent once at Discord Later: a per-role variant uses guilds.members.read and /users/@me/guilds/<id>/member to map Discord roles → Authentik groups automatically.
  • Pin AUTHENTIK_TAG in .env to the version that was installed (don't float on upgrades)

Phase 3 — Jellyfin ✅ done (loose ends → Phase 8)

  • Wizard at https://watch.HOST/: admin user; libraries /media/streams, /media/clips, /media/finals as "Home Videos", all metadata fetchers off
  • Dashboard → Playback → Transcoding: hardware acceleration none; per user untick "Allow video playback that requires transcoding"
  • Plugins → Repositories → add https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json → Catalog → "SSO Authentication" → install → restart
  • Authentik → Applications → Create with provider (OAuth2/OIDC) for Jellyfin, redirect https://watch.HOST/sso/OID/redirect/authentik
  • SSO-Auth → add provider authentik: OID endpoint https://auth.HOST/application/o/<slug>/, client id/secret, roles claim groups, admin roles admins, enabled folders = 3 libraries, default: transcoding disabled; Scheme Override https, Port Override EMPTY; Save the page
  • direct test https://watch.HOST/sso/OID/start/authentik logs a Discord user in (gotchas hit: unsaved provider → "Provider does not exist"; Port Override set → redirect gets :443 → Authentik mismatch; opening the /redirect/ URL by hand → "missing state")
  • login page: Discord button first, local form behind "Admin login" — Dashboard → General → Branding: disclaimer = jellyfin/branding-disclaimer.html (set the hostname), CSS = jellyfin/branding.css
  • button works for a Discord user in editors

Phase 4 — "Vanguard Metadata" Jellyfin plugin (NEXT — usability for everyone)

Decision: this lives inside Jellyfin as a metadata provider plugin, not as server scripts and timers. Files stay exactly where they land (Syncthing mirrors the gamer's folder; streams stay in streams/<user>/<Title>/). No sidecar files, no symlink view, no moves. Jellyfin asks the plugin for metadata and the plugin answers from the filename, the folder, Discord and Authentik.

What the plugin provides (C#, Jellyfin plugin SDK, targets the installed server's ABI)

  • IRemoteMetadataProvider<Series> — show = the <user> folder: display name from Discord, overview from the member's roles/bio
  • IRemoteImageProvider (Series) — poster = the Discord avatar
  • IRemoteMetadataProvider<Season> — season name for a season number (policy / override / event)
  • IRemoteMetadataProvider<Episode> — season + episode numbers from the timestamp (rolling policy below), title from the Discord event overlapping the recording (fallback: the dock title = folder name), overview = event description + duration + streamer, premiere date
  • IScheduledTask "Discord snapshot" — members + scheduled events into the plugin's data store every 5 min (Discord only exposes upcoming/active events; history must be kept)
  • IScheduledTask "Season roll" — nightly after 04:00 Eastern: refresh metadata on items whose bucket changed (weekday → week → month → year). Same path, same item ID → watched state kept.
  • configuration page: Discord bot token + guild ID, Authentik URL + API token (streamer → Discord ID via the OAuth source connection), broadcast-day hour, rolling thresholds, season name overrides, provider on/off per library

Timestamp precedence (plugin-side): bot clip.json sidecar if present (Medal/Twitch/YouTube creation time) → container creation_time (Jellyfin's MediaInfo exposes it) → MediaMTX filename → file date. Normalised to America/New_York.

Rolling seasons by tag (ParentIndexNumber/IndexNumber set by the provider; pure function of timestamp + policy):

age of item season # season name
current week YYYYMMDD "Thursday 30 Oct"
current month, past week YYYYWW (ISO) "Week 44"
current year, past month YYYYMM "October 2026"
past year YYYY "2026"

episode # derived from the timestamp (DDHHMM-style; stable; same-minute collisions get a digit). Broadcast day rolls at 04:00. Season names: generated → override table → Discord event series name when every item in the bucket belongs to one.

Libraries: Shows type for streams and clips with this plugin as the only Series/Season/Episode provider (TheTVDB/TMDB off), Movies type for finals (plugin optional). Jellyfin's scanner treats <user> as the series and any file below it as an episode; the plugin supplies the numbers.

Build & ship: .NET 8 SDK, Jellyfin.Controller matching the server's major version; CI builds the DLL + zip; served from our Caddy as a plugin repository (/plugins/manifest.json) so installs and updates go through Jellyfin's own Catalog. Cost: C#, and a rebuild on each Jellyfin major.

Status: v0.1.1.0 — v0.1.0 installed and verified: Jellyfin honours the provider's season numbers (day seasons appeared on a Shows library). v0.1.1 adds the playback-policy task. Providers, Discord/Authentik clients, event history, season-roll + snapshot tasks, Discord feed with frame-grab attachment, config page; 10 unit tests.

Checklist:

  • skeleton + core season policy (tests) + providers + tasks + feed → ./package.sh → dist/VanguardMetadata_0.1.0.0.zip
  • Discord dev portal → the existing Authentik application → Bot → Reset Token; enable Server Members intent; OAuth2 URL generator: scope bot, perms View Channels + Send Messages + Embed Links + Attach Files; invite to the server
  • Authentik: read-only service account + API token for the plugin
  • install the zip (README in the plugin folder), restart Jellyfin, configure the plugin page
  • libraries → Shows type; providers: only Vanguard; scan — day seasons appeared
  • install v0.1.1 (./installJellyfinPlugin.sh), run "enforce playback policy" once, check a user's policy
  • verify: seasons appear as "Thursday, Oct 29" / "Week 44" / "October 2026"; poster = avatar; a new file posts to the feed channel with the frame grab
  • Medal local filename pattern → add to ItemTimestamp (need a sample filename)
  • later: serve dist/ at /plugins/ via Caddy as a plugin repo; theme CSS

Phase 5 — copyparty behind Authentik ✅ live

  • Authentik → Providers → Proxy provider, forward auth (single application), external host https://ovh1.HOST; Application bound to it; add to the embedded outpost
  • Caddy: forward_auth 127.0.0.1:9000 { uri /outpost.goauthentik.io/auth/caddy; copy_headers X-Authentik-Username X-Authentik-Groups … } on /, /dock*, /dump*, /sync*; reverse_proxy /outpost.goauthentik.io/* 127.0.0.1:9000
  • copyparty: idp-h-usr: X-Authentik-Username, idp-h-grp: X-Authentik-Groups, xff-src = Caddy only; volume accs become r: @editors, rw: @editors (finals), rwmd: @media-admins on every volume incl. trash (new group media-admins; techy joins it via a Discord role in the roles→groups table or a manual add — replaces today's workaround of logging in as the local admin account to modify streams/clips)
  • keep the local accounts in copyparty.conf until an editors Discord user has browsed and downloaded through the new path; then remove them (configure.py drops the editors section)
  • decided: no WebDAV (Discord login only); browser downloads
  • policy on the Application: only editors + admins + streamers may pass

Phase 6 — Syncthing UI + dump behind Authentik ✅ live

  • /sync/: forward-auth, policy admins only; Syncthing GUI auth can stay as a second layer
  • dumpd.py: read X-Authentik-Username / X-Authentik-Groups from Caddy instead of basic auth; admin = member of admins; drop dump-users.txt
  • dock: Dump tonight sends no credentials; session cookie carries identity

Phase 7 — dock polish + cleanup ✅ live (dock mints tokens; no stored credentials)

  • 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
  • configure.py shrinks: hostnames + streamers only (editors and admin live in Authentik)
  • README: replace the per-app login notes with "log in with Discord"

Phase 8 — hardening (loose ends from phases 2–3, plus backups)

  • Jellyfin login branding: Discord button first, "Admin login" reveals the local form (jellyfin/branding-disclaimer.html + jellyfin/branding.css)
  • group check: a Discord user in editors but not admins sees the three libraries and is NOT a Jellyfin administrator; one in admins is
  • per-user transcoding OFF for every user, automatically: the Vanguard plugin's "enforce playback policy" task (startup + every 30 min) sets direct-play-only on all non-admin users, SSO-created ones included. Config toggle EnforceDirectPlayOnly; turn off on the SYS-3
  • pin Authentik: .env → AUTHENTIK_TAG=<tag now running> (docker inspect authentik-server --format '{{.Config.Image}}')
  • guild restriction so only members of our Discord server can log in
  • rotate the Discord client secret that appeared in a screenshot; paste the new one into the source
  • nightly pg_dump of the Authentik database into /srv/appdata/authentik/backup/ (timer), keep 14 days; Jellyfin config and Authentik media are plain directories, covered by the archive/backup of /srv/appdata
  • Jellyfin: library "real time monitoring" on, metadata fetchers confirmed off, scheduled scan daily

Discord bot (separate dev) — runtime provided by the stack

  • mariadb container, db vanguardbot / user bot, loopback only (ssh tunnel for dev access)
  • discord-bot container: python 3.12 + ffmpeg + curl, and no bot dependencies — its CI builds a release bundle (code + vendor/ wheels) that dumpd installs into /srv/bot/app, bind-mounted at /app; env gives token, guild, DB creds, rw /media/clips, /data scratch; restarts on exit
  • web UI at bot. behind Caddy + Authentik forward-auth (first use of the forward-auth pattern that Phase 5 applies to copyparty); the bot trusts X-Authentik-* headers and implements no login
  • bot/AGENT.md is the build brief for the agent writing the bot; bot/README.md the ops cheat-sheet. The clip-submissions feature (Option below) is a natural home for this bot: write clips/<user>/<name>.mp4 + <name>.json sidecar and the Jellyfin plugin picks it up.
  • CNAME bot → main host; configure.py --full (asks the hostname, bot token + guild) → docker compose up -d --build
  • Authentik: domain-level Proxy Provider (covers live/bot/main); Caddy requires group bot-users on the bot host (BOT_REQUIRE_GROUP)
  • Discord roles → Authentik groups: authentik/discord-roles-to-groups.py on both source flows (scope guilds.members.read)
  • the bot repo's CI builds a release bundle and a push to main installs it (signed, via dumpd /deploy-bot)

Phase 9 — Gitea + Actions runner

  • gitea on MariaDB (db/user from mariadb/init/10-gitea.sh on first init, else ./createGiteaDb.sh), git. via Caddy, ssh 2222 (firewall opened), registration closed, Actions enabled
  • act_runner with the Docker socket; labels ubuntu-latest / ubuntu-22.04 / dotnet / python
  • CNAME git → main host; ./configure.py --full (asks the hostname) or --hosts AUTH WATCH GIT
  • https://git.HOST/ install page → admin account
  • ./registerGiteaRunner.sh; confirm the runner is idle under Site Administration → Actions → Runners
  • create users (or OIDC via Authentik, same pattern as Jellyfin: Gitea → Authentication Sources → OpenID Connect)
  • first use: push vanguard-stream itself; a workflow that runs package.sh on the dotnet label and publishes the plugin zip is the obvious first pipeline
  • risk: the runner's Docker socket is root on the host for anyone who can push a workflow

Phase 10 — Gitea login via Authentik (OIDC) ✅ live, verified

Same pattern as Jellyfin: Authentik is the identity, Gitea trusts it.

  • Authentik → Applications → Create with provider → OAuth2/OpenID: name gitea, redirect https://git.HOST/user/oauth2/authentik/callback, authorization flow implicit-consent, scopes openid email profile (+ groups claim via the default profile mapping)
  • Gitea → Site Administration → Identity & Access → Authentication Sources → Add → OAuth2, provider OpenID Connect, name authentik (must match the callback path above), client ID/secret, discovery URL https://auth.HOST/application/o/<slug>/.well-known/openid-configuration, "Skip local 2FA", claim mapping: group claim groups → Gitea admin group admins
  • Gitea env: DISABLE_REGISTRATION=false + ALLOW_ONLY_EXTERNAL_REGISTRATION=true (local self-signup stays off), ENABLE_AUTO_REGISTRATION, ACCOUNT_LINKING=auto, USERNAME=nickname, UPDATE_AVATAR
  • optional: hide the password form on the login page (custom template) so Discord is the only visible way in
  • Authentik groups git-users and git-admins bound on the Gitea application; Gitea admin claim = git-admins; nesting authentik Admins → admins → git-admins so admins inherit it
  • test with a non-admin Discord user; confirm admin mapping via admins group

Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere) ✅ live

Implemented variant: short-lived HMAC tokens minted by dumpd for Discord-authenticated members of streamers (no Authentik app passwords — "Discord only" decision). Dock and portal mint a token per Go Live; manual setups get it from the dock's accordion. MediaMTX authMethod: http → dumpd /mtx-auth on 8081 (Docker bridge only). Hot-reloaded: no MediaMTX restart. 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

Phase 12 — frame-accurate timecode for multicam via an overscan band ✅ live, verified (OBS: strip, −3 ms drift, 0 gaps)

Target: clips from several streamers align to the frame (16.7 ms @60) on a multicam timeline; the game image is untouched and finals carry nothing. Reference clock = the server; streamer PC clocks are never trusted.

Facts that shape it: within a recording, frame timing is the encoder's capture clock (network lag only shifts arrival, which is measured once); but a PC clock drifts vs the server (10 ppm ≈ 4 frames over 2 h), so a permanent reference is needed for long sessions, not just a start burst.

  • Overscan band: the published canvas is the game image plus 16 extra rows at the bottom. The band holds a machine-readable strip (black/white cells: server time in ms + checksum + framing, cell size scaled to the canvas) and a small human-readable server clock, continuously.
    • Go Live: canvas composite = video (W×H) + band → W×(H+16); the /clock/ logic runs in-page.
    • OBS: the dock (obs-websocket) offers "add overscan band": SetVideoSettings canvas/output to H+16 while not streaming, CreateInput browser_source → /clock/ positioned in the band, toggle/remove. The browser source shares the dock's cookies (Authentik). Opt-out = coarse timecode only.
    • /clock/ measures its offset to the server clock over HTTPS (±few ms) and shows server time.
  • Sweep: when publishing a stream: extract frames at the start, middle and end; locate + decode the strip; frame-0 capture time = median(decoded − pts) at the start (frame-accurate) → QuickTime tmcd track (time of day, Eastern, file fps) + creation_time (UTC) in the existing lossless remux; a .tc.json sidecar with the samples records drift over the file. No strip → server arrival time (coarse), logged. Strip present → the band is cropped off in a re-encode (decided 2026-11-01: finals = picture + audio + tmcd, nothing else); no strip → lossless remux, never crop.
  • Viewing: live portal crops the band on the live picture (canvas/overflow fix pending); recordings are already at game resolution.
  • MediaMTX recordPath with %f (microseconds) for the fallback; plugin ItemTimestamp prefers creation_time.
  • /clock/ page: offset measurement, strip encoder, readable clock, band layout
  • dock: overscan band setup + browser source via obs-websocket; opt-out
  • portal Go Live: band composite (toggle); portal viewer: CSS crop of the band
  • sweep: bin/vgstrip.py (ffmpeg frames → decode → offset + drift), tmcd + creation_time, <stem>.json sidecar (read by the plugin)
  • MediaMTX %f; plugin timestamp update; README editor workflow (multicam: timecode → audio cross-check; export at 2560×1440)
  • rejected: LTC audio (SRT multitrack + per-PC generator); clock inside the picture; cropping at remux (re-encode)

Phase 13 — push-to-deploy (built; OFF — DEPLOY_AUTO=0, CI workflow in draft-workflows/)

  • server folder = git clone (switchToClone.sh); generated files gitignored and preserved
  • vanguard-deploy pull|apply|rollback|pending|status applies only the changed parts; check.sh gates it; MediaMTX restarts are deferred while anything is live (deploy-pending.timer)
  • Gitea push webhook → dumpd /deploy (bridge port, HMAC) → vanguard-deploy pull when DEPLOY_AUTO=1
  • CI (draft): check.sh + plugin tests/build on the runner; the runner never touches the host
  • run switchToClone.sh key / add deploy key / switchToClone.sh clone on the server
  • ./configure.py --apply once (installs vanguard-deploy, deploy.env, the pending timer)
  • add the Gitea webhook (secret from .env); watch journalctl -u dumpd | grep deploy for a few pushes
  • when trusted: DEPLOY_AUTO=1 in .env + ./configure.py --apply; move ci.yml into .gitea/workflows/

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 setup have no way in. Two additions cover everyone, and neither depends on Authentik.

A. #clip-submissions Discord bot (one container, outbound-only, no new ports)

  • member posts a Medal.tv / Twitch clip / YouTube / Kick / Streamable link, or drops a file
  • bot maps the poster's Discord username → /srv/media/clips/<username>/ (same identity Authentik uses)
  • downloads at best quality with yt-dlp (has extractors for all of the above incl. Medal)
  • writes clip.json beside it: source URL, title, submitter, timestamp — context for editors
  • reacts ✅ / ❌ on the message; replies with the reason on failure
  • normalize-sweep handles whatever arrives (Medal's HEVC/VFR etc.)
  • config: bot token + channel id in .env; yt-dlp pinned, self-updates weekly (extractors rot)
  • quality note: Medal/Twitch/YouTube deliver their re-encode (~1080p). Fine for clips; originals still come via Syncthing or upload below.
  • skipped on purpose: polling Medal profiles via its API — needs per-member IDs, pulls clips nobody submitted, and only returns embed URLs; the bot covers it with explicit intent.

B. Web upload for members (after Phase 5)

  • copyparty per-user volume clips/${u} writable by its owner, readable by editors/admins
  • anyone who can log in with Discord drags an original into the browser or WebDAV — full quality, no bot, no install
source path in quality
local clipper + Syncthing automatic original
Medal / Twitch / YouTube / Kick link bot (A) platform re-encode
Discord attachment bot (A) original, Discord size cap
file in hand web upload (B) original

Checklist if adopted:

  • Discord developer portal → bot user, Message Content intent, invite to the server with read/react/reply in #clip-submissions
  • bot/ in the bundle: python + discord.py + yt-dlp, runs as uid 1000, mounts /srv/media/clips
  • .env: DISCORD_BOT_TOKEN, CLIPS_CHANNEL_ID
  • per-user clips volume in copyparty.conf once forward-auth is live (Phase 5)

Later — when it hurts

  • Off-site gap filling (instead of burning the SYS-1's CPU): streams whose sidecar reports gaps.total_missing_ms ≥ threshold are re-encoded elsewhere with a hardware encoder. - the sweep keeps the raw recording in .src/ and the sidecar flags it (needs_fill: true) - a nightly job on the home box (RTX 2080 Ti, NVENC) or a Spot GPU instance pulls flagged raws over SFTP, re-encodes to CFR with duplicated frames (same editing standard, tmcd + creation_time carried from the sidecar), pushes the result back over the published file, clears the flag - Jellyfin keeps the same path → item identity and watched state survive; the plugin re-reads metadata - until then: FILL_GAPS_MS stays off; players hold frames across gaps, editors see the gap list
  • SYS-3 (E-2288G, Quick Sync) — the 2×6 TB disk option is currently unavailable; re-check and swap when it returns. Then: Jellyfin transcoding on, sweep re-encodes in minutes, FILL_GAPS_MS can simply be on.
  • SYS-3 (E-2288G): Quick Sync → Jellyfin transcoding on, sweep re-encodes in minutes. Swap, not redesign: same compose, same /srv layout; restore /srv/appdata + /srv/media.
  • home archive puller (home/rclone-archive.sh) on the home box, nightly cron

Phase 14 — diag. host: logs now, ops controls later

Decided 2026-11-03: VictoriaLogs, on its own hostname diag.BASE, with the log UI at diag.BASE/logs. Rejected: ELK (a JVM heap and a growing index on a box that spends its nights running x264, to search one host's logs), and Grafana + Loki (two services and a dashboard tool we would only use as a log viewer). VictoriaLogs is one container with its own UI and the same /metrics endpoint style as the taps we already expose. The framework everything feeds from is LOGGING.md; this phase only adds the store and the collector.

  • CNAME diag → the main host
  • configure.py: new host role diag, Caddy block gated by DIAG_REQUIRE_GROUP (default media-admins)
  • victoria-logs container, 127.0.0.1:9428, data in /srv/appdata/victorialogs, run with -http.pathPrefix=/logs so its own UI emits /logs/... URLs; Caddy uses handle /logs* and does not strip the prefix (stripping breaks vmui's asset paths). redir /logs /logs/select/vmui/ 302 for humans.
  • collector: Vector, one container, two sources — journald for our units (dumpd, normalize-sweep, trash-purge, deploy-pending, vgdeband) and Docker logs for the 15 containers. The vanguard.service / vanguard.category labels already on every container become stream fields, so filtering by category works without parsing. Parse stages per source as LOGGING.md lists them (JSON for caddy/authentik, logfmt for syncthing and our own scripts, one regex for mediamtx, multiline + regex for jellyfin).
  • Decide at build time how Vector reads container logs. Its docker_logs source needs the Docker API, and mounting the socket is root-equivalent on this host — the same exposure act_runner already has. The alternative is a read-only socket proxy in front of it, one more container but no root. Reading the json files directly avoids both and loses the container labels, which is most of the value.
  • retention: one global -retentionPeriod (start at 30d and watch the disk). Keeping auth and deploy longer than the rest would mean separate tenants — not worth it unless an audit needs it.
  • switch on the metrics scrape at the same time if wanted: the four taps are already live (node_exporter 9100, cAdvisor 9101, MediaMTX 9998, Caddy 2019) and VictoriaMetrics beside VictoriaLogs would consume them. Not required for logs.
  • alerts (the first thing that actually protects the stack): disk on /srv over 85 %, a sweep event=failed, a container restart loop, MediaMTX dropped packets rising on a live path → Discord webhook.

Later on the same host — ops controls. Restart buttons and similar, so diag. becomes the one place an admin looks and acts. Design note so it is not built the easy wrong way: the page must not get Docker access. Put each action behind a dumpd endpoint with a fixed allowlist (restart this named service, run the sweep now, purge trash now), authorise from the Authentik headers as everything else does, and log every invocation as a logfmt event so the action lands in the same place as the logs it is diagnosing. No generic "run a command".

Risks / gotchas

  • Phase 5 lockout. Keep one SSH session open and a copyparty local admin account until the Authentik path is proven. Caddy config errors stop all sites — docker logs caddy first.
  • Authentik outpost header names differ by version (X-Authentik-Username, X-Authentik-Groups, X-Authentik-Email). Verify with a test route that echoes headers before wiring copyparty.
  • Jellyfin SSO plugin creates users with transcoding allowed by default unless the provider's defaults are set — set them, or the per-user toggle has to be fixed after every first login.
  • RAM: Authentik ~1.5 GB, Jellyfin ~0.5 GB idle. Fine on 32 GB; check free -h after phase 1.
  • nftables: never systemctl stop|restart nftables (flushes Docker's NAT). Reload only.
  • Firewall unchanged: Authentik (9000) and Jellyfin (8096) bind to 127.0.0.1; only Caddy is public.

Monthly cost

Unchanged: the SYS-1 (~$30 + disks). No new services cost money. Discord OAuth is free.