30 KiB
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 (bot/app) |
in bundle, not yet applied |
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.andwatch.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) viaconfigure.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 -hsane
Phase 2 — Authentik + Discord ✅ done (loose ends → Phase 8)
https://auth.HOST/if/flow/initial-setup/→ setakadminpassword (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:guilds3. Customization → Policies → Create Expression Policydiscord-guild-member, paste the file, setACCEPTED_GUILD_ID/GUILD_NAME4. bind it todefault-source-enrollmentanddefault-source-authentication(flow → Policy / Group / User Bindings → Bind existing policy), then edit both flows → Policy engine mode all (withany, 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 usesguilds.members.readand/users/@me/guilds/<id>/memberto map Discord roles → Authentik groups automatically. - Pin
AUTHENTIK_TAGin.envto 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/finalsas "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 endpointhttps://auth.HOST/application/o/<slug>/, client id/secret, roles claimgroups, admin rolesadmins, enabled folders = 3 libraries, default: transcoding disabled; Scheme Overridehttps, Port Override EMPTY; Save the page - direct test
https://watch.HOST/sso/OID/start/authentiklogs 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/bioIRemoteImageProvider(Series) — poster = the Discord avatarIRemoteMetadataProvider<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 dateIScheduledTask"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 becomer: @editors,rw: @editors(finals),rwmd: @media-adminson every volume incl. trash (new groupmedia-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 localadminaccount to modify streams/clips) - keep the local accounts in
copyparty.confuntil aneditorsDiscord user has browsed and downloaded through the new path; then remove them (configure.pydrops the editors section) - decided: no WebDAV (Discord login only); browser downloads
- policy on the Application: only
editors+admins+streamersmay pass
Phase 6 — Syncthing UI + dump behind Authentik ✅ live
/sync/: forward-auth, policyadminsonly; Syncthing GUI auth can stay as a second layerdumpd.py: readX-Authentik-Username/X-Authentik-Groupsfrom Caddy instead of basic auth; admin = member ofadmins; dropdump-users.txt- dock:
Dump tonightsends no credentials; session cookie carries identity
Phase 7 — dock polish + cleanup ✅ live (dock mints tokens; no stored credentials)
dumpd.pygainsGET /me→{user, groups, stream_key}for the logged-in streamer; dock fills OBS settings from it — nobody types a stream password againconfigure.pyshrinks: 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
editorsbut notadminssees the three libraries and is NOT a Jellyfin administrator; one inadminsis - 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_dumpof 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
mariadbcontainer, dbvanguardbot/ userbot, loopback only (ssh tunnel for dev access)discord-botcontainer: python 3.12 + ffmpeg + yt-dlp baseline; code bind-mounted frombot/app/; env gives token, guild, DB creds, rw/media/clips,/datascratch; 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 trustsX-Authentik-*headers and implements no login bot/AGENT.mdis the build brief for the agent writing the bot;bot/README.mdthe ops cheat-sheet. The clip-submissions feature (Option below) is a natural home for this bot: writeclips/<user>/<name>.mp4+<name>.jsonsidecar 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-userson the bot host (BOT_REQUIRE_GROUP) - Discord roles → Authentik groups:
authentik/discord-roles-to-groups.pyon both source flows (scopeguilds.members.read) - dev drops
main.pyintobot/app/→docker compose restart discord-bot
Phase 9 — Gitea + Actions runner
giteaon MariaDB (db/user frommariadb/init/10-gitea.shon first init, else./createGiteaDb.sh),git.via Caddy, ssh 2222 (firewall opened), registration closed, Actions enabledact_runnerwith 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-streamitself; a workflow that runspackage.shon thedotnetlabel 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, redirecthttps://git.HOST/user/oauth2/authentik/callback, authorization flow implicit-consent, scopes openid email profile (+groupsclaim 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 URLhttps://auth.HOST/application/o/<slug>/.well-known/openid-configuration, "Skip local 2FA", claim mapping: group claimgroups→ Gitea admin groupadmins - 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-usersandgit-adminsbound on theGiteaapplication; Gitea admin claim =git-admins; nestingauthentik Admins→admins→git-adminsso admins inherit it - test with a non-admin Discord user; confirm admin mapping via
adminsgroup
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 toany, Caddy gates viewers). MediaMTX posts user/pass/token/action/path/ip. - Browser (Go Live, WHEP): Caddy forwards Authentik's
X-Authentik-JwtasAuthorization: Beareron the/webrtchop. dumpd verifies the JWT against Authentik's JWKS (/application/o/<slug>/jwks/), readspreferred_username+groups, allows publish iffpathstarts with<username>/and user ∈ streamers. No/me/stream-key, nostreamers.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-passwordagainst 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; keepanyread; dropauthInternalUsers - 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.
- Go Live: canvas composite = video (W×H) + band → W×(H+16); the
- 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
tmcdtrack (time of day, Eastern, file fps) +creation_time(UTC) in the existing lossless remux; a.tc.jsonsidecar with the samples records drift over the file. No strip → server arrival time (coarse), logged. Never re-encode, never crop. - Viewing: live portal crops the band with CSS; Jellyfin shows a thin strip at the bottom edge (VHS-style). Editors export at the game resolution (2560×1440), which drops the band.
- MediaMTX
recordPathwith%f(microseconds) for the fallback; pluginItemTimestamppreferscreation_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>.jsonsidecar (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|statusapplies only the changed parts;check.shgates it; MediaMTX restarts are deferred while anything is live (deploy-pending.timer)- Gitea push webhook → dumpd
/deploy(bridge port, HMAC) →vanguard-deploy pullwhenDEPLOY_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 cloneon the server ./configure.py --applyonce (installs vanguard-deploy, deploy.env, the pending timer)- add the Gitea webhook (secret from
.env); watchjournalctl -u dumpd | grep deployfor a few pushes - when trusted:
DEPLOY_AUTO=1in.env+./configure.py --apply; moveci.ymlinto.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.jsonbeside 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 byeditors/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.confonce 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_timecarried 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_MSstays 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_MScan simply be on. - SYS-3 (E-2288G): Quick Sync → Jellyfin transcoding on, sweep re-encodes in minutes.
Swap, not redesign: same compose, same
/srvlayout; restore/srv/appdata+/srv/media. - home archive puller (
home/rclone-archive.sh) on the home box, nightly cron
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 caddyfirst. - 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 -hafter 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.