Vanguard media stack: ingest, copyparty, Syncthing, normalize sweep, Authentik, Jellyfin + Vanguard Metadata plugin, MariaDB, Gitea, bot runtime
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# copy to .env (configure.py --full generates it) — never commit the real one
|
||||||
|
AUTHENTIK_SECRET_KEY=
|
||||||
|
AUTHENTIK_PG_PASS=
|
||||||
|
AUTHENTIK_TAG=2025.8
|
||||||
|
WATCH_HOST=watch.example.com
|
||||||
|
MARIADB_ROOT_PASSWORD=
|
||||||
|
BOT_DB_NAME=vanguardbot
|
||||||
|
BOT_DB_USER=bot
|
||||||
|
BOT_DB_PASSWORD=
|
||||||
|
DISCORD_BOT_TOKEN=
|
||||||
|
DISCORD_GUILD_ID=
|
||||||
|
GIT_HOST=git.example.com
|
||||||
|
GITEA_DB_PASSWORD=
|
||||||
|
GITEA_RUNNER_TOKEN=
|
||||||
|
BOT_HOST=bot.example.com
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# never commit real secrets; copy the templates and edit the copies on the server
|
||||||
|
*.local.*
|
||||||
|
__pycache__/
|
||||||
|
.env
|
||||||
|
bot/app/__pycache__/
|
||||||
|
*.pyc
|
||||||
29
Caddyfile
Normal file
29
Caddyfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
media.example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# dump endpoint (python service on the host, does its own auth)
|
||||||
|
handle /dump* {
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
# Syncthing admin UI (its own login; strip the prefix, present a local Host to pass its host check)
|
||||||
|
redir /sync /sync/ 308
|
||||||
|
handle_path /sync/* {
|
||||||
|
reverse_proxy 127.0.0.1:8384 {
|
||||||
|
header_up Host {upstream_hostport}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# OBS browser dock (static)
|
||||||
|
handle_path /dock* {
|
||||||
|
root * /srv/dock
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# everything else is copyparty (web UI + WebDAV)
|
||||||
|
handle {
|
||||||
|
reverse_proxy 127.0.0.1:3923 {
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
317
CommunityDiscordMedia.md
Normal file
317
CommunityDiscordMedia.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# 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.` 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.
|
||||||
|
- **WebDAV through Authentik** uses Authentik app passwords as HTTP basic auth (a mounted drive
|
||||||
|
can't do a browser login).
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
- [x] 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:
|
||||||
|
- [x] 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
|
||||||
|
- [x] 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 (backend, few users; the one that can lock editors out)
|
||||||
|
- [ ] 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: @admins`
|
||||||
|
- [ ] 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)
|
||||||
|
- [ ] WebDAV: editor creates an app password in Authentik → mounts with Discord username + app password
|
||||||
|
- [ ] policy on the Application: only `editors` + `admins` + `streamers` may pass
|
||||||
|
|
||||||
|
### Phase 6 — Syncthing UI + dump behind Authentik
|
||||||
|
- [ ] `/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
|
||||||
|
- [ ] `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
|
||||||
|
- [x] 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}}'`)
|
||||||
|
- [x] 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 + yt-dlp baseline; code bind-mounted from `bot/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: Proxy Provider (forward auth, single app, external host `https://bot.HOST`) → embedded outpost → group policy
|
||||||
|
- [ ] dev drops `main.py` into `bot/app/` → `docker compose restart discord-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) (later)
|
||||||
|
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 app.ini / env: `GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION=true`,
|
||||||
|
`GITEA__oauth2_client__ACCOUNT_LINKING=auto`, `GITEA__oauth2_client__USERNAME=nickname` so first
|
||||||
|
Discord login creates the Gitea user with no form (registration stays closed for local accounts)
|
||||||
|
- [ ] optional: `GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION=true`, hide the password form on the
|
||||||
|
login page (`GITEA__oauth2_client__OPENID_CONNECT_SCOPES`, custom template) so Discord is the only way in
|
||||||
|
- [ ] policy on the Authentik application: who may reach Gitea at all (e.g. `admins` + a `devs` group)
|
||||||
|
- [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- [ ] **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
|
||||||
|
|
||||||
|
## 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.
|
||||||
255
README.md
Normal file
255
README.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# vanguard-stream — SYS-1 (Xeon E-2136, 32 GB ECC, 2×4 TB SATA soft RAID)
|
||||||
|
|
||||||
|
> **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)
|
||||||
|
|
||||||
|
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 `copyparty.conf`,
|
||||||
|
regenerates those three plus `dump-users.txt`, installs the dump users to `/etc/vanguard`, creates
|
||||||
|
`streams/<name>` and `clips/<name>` (with `.stignore`) for each streamer, restarts dumpd and the
|
||||||
|
containers. Removing a streamer or editor revokes access 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) |
|
||||||
|
|
||||||
|
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://HOST/sync/` behind Syncthing's own GUI login (set it in
|
||||||
|
Actions → Settings → GUI before relying on this). The ssh tunnel to 8384 still works as a fallback.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
OBS → Settings → Stream → Custom: Server `rtmp://media.example.com:1935/`, key anything (the dock sets it).
|
||||||
|
OBS → Tools → WebSocket Server Settings → enable, set a password.
|
||||||
|
OBS → Docks → Custom Browser Docks → `https://media.example.com/dock/`.
|
||||||
|
In the dock: fill host/user/pass/OBS-ws-password once (stored in the dock's localStorage).
|
||||||
|
Per stream: type a title, click **Go Live**. After a bad night: **Dump tonight**.
|
||||||
|
|
||||||
|
Stream key format the dock produces: `techy/Title?user=techy&pass=...`
|
||||||
|
SRT equivalent if preferred: `srt://media.example.com:8890?streamid=publish:techy/Title:techy:PASS`
|
||||||
|
|
||||||
|
## 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 `#include`d 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://HOST/sync/):
|
||||||
|
- 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` and `watch` as CNAMEs to the main host. Then run `./addCommunityMediaServer.sh` — it asks
|
||||||
|
for both hostnames, 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 `bot/app/main.py` and idles until that file
|
||||||
|
exists. 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).
|
||||||
|
|
||||||
|
The bot also serves a web UI 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`. 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.
|
||||||
|
|
||||||
|
The build brief for whoever writes the bot is `bot/AGENT.md`; `bot/README.md` is the ops cheat-sheet.
|
||||||
|
|
||||||
|
docker compose logs -f discord-bot # watch it
|
||||||
|
docker compose restart discord-bot # after code changes
|
||||||
|
docker compose build discord-bot && docker compose up -d discord-bot # after requirements.txt changes
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 6. Editors
|
||||||
|
|
||||||
|
Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want.
|
||||||
|
WebDAV mount: same URL, same login. Upload finished work to `/finals`.
|
||||||
|
|
||||||
|
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 (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.
|
||||||
|
|
||||||
|
## Layout on the server
|
||||||
|
|
||||||
|
/srv/media/streams/<user>/<Title>/2025-10-10_21-03-11.mp4 stream, remuxed flat (lossless) by the sweep
|
||||||
|
/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/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 (fragmented MP4, good codecs) | lossless remux to flat MP4, replaced in place |
|
||||||
|
| 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 at /dump |
|
||||||
78
STATUS.md
Normal file
78
STATUS.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Vanguard stack — what runs, what people do, what's unfinished
|
||||||
|
|
||||||
|
Living document. Updated whenever something is deployed or changes behaviour. Last update: 2026-10-30.
|
||||||
|
Host: `ovh1.dumpstervigilantes.rip` (OVH SYS-1, Debian 13). Bundle: `~/vanguard-stream` on the box, `vanguard-stream/` in this repo.
|
||||||
|
|
||||||
|
## Runs on its own
|
||||||
|
|
||||||
|
| Cadence | What happens | Done by |
|
||||||
|
|---|---|---|
|
||||||
|
| Continuous | OBS streams land as files the moment Go Live is pressed, one per stream under `streams/<user>/<Title>/` | MediaMTX |
|
||||||
|
| Continuous | Gamers' clip folders mirror onto the server (video files only, their structure untouched, receive-only) | Syncthing |
|
||||||
|
| Continuous | New recordings and clips are announced in a Discord channel with the streamer's name and a frame grab | Jellyfin plugin (needs bot token + channel in its config) |
|
||||||
|
| Every 5 min | Finished files are checked. Streams: lossless remux so they seek instantly. Non-compliant clips: converted to the editing standard, raw removed, one copy per clip | normalize sweep (systemd timer) |
|
||||||
|
| Every 5 min | Discord scheduled events snapshotted into a history so recordings can be matched to their session | Jellyfin plugin |
|
||||||
|
| Every 30 min + startup | Every non-admin Jellyfin user set to direct-play-only (no hardware encoder on this box) | Jellyfin plugin |
|
||||||
|
| Daily 04:10 ET | Recordings roll between weekday → week → month → year seasons as the calendar moves; no files change | Jellyfin plugin |
|
||||||
|
| Daily 05:00 ET | Dumped nights older than 3 days deleted for good | trash purge (systemd timer) |
|
||||||
|
| On boot | Everything restarts itself after the media mount is up; interrupted recordings stay playable | systemd + Docker |
|
||||||
|
| **Not yet running** | Pull of files older than 15 days to the home HDD | `home/rclone-archive.sh`, needs the home box |
|
||||||
|
| **Not yet running** | Nightly backup of the Authentik database (Postgres) | planned |
|
||||||
|
|
||||||
|
## What people do
|
||||||
|
|
||||||
|
| Who | Activity | Where | Login |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Streamer | Type a title, Go Live, Stop. "Dump tonight" if it was junk (3-day undo) | OBS browser dock `https://ovh1…/dock/` | stream password in dock settings |
|
||||||
|
| Gamer | Nothing; clips save where they always did | their clipper + Syncthing, paired once | — |
|
||||||
|
| Editor | Browse by streamer/title, scrub in browser, download originals, upload finals | copyparty `https://ovh1…/` | **local account** (to move to Discord) |
|
||||||
|
| Editor | Mount the library as a drive | WebDAV, same URL | same |
|
||||||
|
| Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) |
|
||||||
|
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) |
|
||||||
|
| Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists |
|
||||||
|
| Admin | Add/remove streamers & editors, hostnames, bot token | `./configure.py` | ssh |
|
||||||
|
| Admin | Pair a gamer's device, approve their folder | Syncthing UI `https://ovh1…/sync/` | Syncthing GUI password |
|
||||||
|
| Admin | Groups, who may log in, app access | Authentik `https://auth.…/` | Discord or akadmin |
|
||||||
|
| Admin | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh |
|
||||||
|
| Admin | Register the CI runner | `./registerGiteaRunner.sh` | ssh |
|
||||||
|
|
||||||
|
## Services and their state
|
||||||
|
|
||||||
|
| Service | Role | State |
|
||||||
|
|---|---|---|
|
||||||
|
| MediaMTX | RTMP/SRT ingest, records fMP4 | live |
|
||||||
|
| copyparty | browse/scrub/download/WebDAV/finals upload | live, local accounts |
|
||||||
|
| dumpd + dock | dump-tonight endpoint, OBS dock page | live, own password file |
|
||||||
|
| Syncthing | clip intake, UI at `/sync/` | live, Syncthing GUI login |
|
||||||
|
| normalize sweep | editing standard, one copy per clip | live |
|
||||||
|
| nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live |
|
||||||
|
| Caddy | TLS + routing for all hostnames | live |
|
||||||
|
| Authentik (+Postgres, Redis) | Discord login, groups, guild-membership policy | live |
|
||||||
|
| Jellyfin | watch page, SSO via Authentik, direct play only | live |
|
||||||
|
| Vanguard Metadata plugin | seasons by tag, Discord names/avatars/events, feed, playback policy | **0.1.0 live, 0.1.1 built** (policy task) — install pending |
|
||||||
|
| MariaDB | databases for the bot and Gitea; public :3306, root local-only | live |
|
||||||
|
| Gitea | git hosting on MariaDB, Actions enabled | live, local admin |
|
||||||
|
| act_runner | CI jobs in Docker | **not registered** |
|
||||||
|
| discord-bot | Python runtime, idles until `bot/app/main.py` exists | live, no code yet |
|
||||||
|
| `bot.` host | bot web UI behind Authentik forward-auth | Caddy block ready; **CNAME + Authentik proxy provider pending** |
|
||||||
|
|
||||||
|
## Unfinished, in rough priority
|
||||||
|
|
||||||
|
1. **Install plugin 0.1.1** (`./installJellyfinPlugin.sh`), run "enforce playback policy", check a user.
|
||||||
|
2. **Register the runner** (`./registerGiteaRunner.sh`).
|
||||||
|
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty.
|
||||||
|
4. **copyparty behind Authentik** (group permissions, WebDAV via app passwords, then drop local accounts). The one change that can lock editors out; keep a local admin until a Discord editor has used the new path.
|
||||||
|
5. **Syncthing UI + dump behind Authentik**; dump reads the identity header; dock fetches the caller's own stream key.
|
||||||
|
6. **Gitea login via Authentik** (OIDC source, auto-register, `admins` → Gitea admin).
|
||||||
|
7. **Housekeeping**: pin `AUTHENTIK_TAG` in `.env`, rotate the Discord client secret that was in a screenshot, nightly Postgres dump, Jellyfin login branding (Discord button first), check a non-admin Discord user's Jellyfin view.
|
||||||
|
8. **Medal filename pattern** in the plugin's timestamp parser — need one sample filename.
|
||||||
|
9. **Home archive puller** on the home box.
|
||||||
|
10. **Plugin repository** served by Caddy so Jellyfin updates the plugin from its catalog.
|
||||||
|
11. **Clip submissions** (Medal/Twitch/YouTube links, Discord uploads) — belongs to the bot.
|
||||||
|
12. **SYS-3** when transcoding or sweep speed hurts: Quick Sync, flip the plugin's policy toggle off.
|
||||||
|
|
||||||
|
## Verified behaviours worth remembering
|
||||||
|
- Jellyfin honours provider-supplied season numbers on a Shows library (day seasons appeared). Next check: Monday 04:10 ET should fold them into a week season.
|
||||||
|
- Sweep leaves compliant Syncthing files untouched and keeps exactly one copy of converted ones (tested).
|
||||||
|
- Firewall: Docker-published ports bypass the input chain; MariaDB uses host networking precisely so the rules apply.
|
||||||
|
- Never `systemctl stop|restart nftables` (flushes Docker's NAT). Reload only.
|
||||||
166
addCommunityMediaServer.sh
Executable file
166
addCommunityMediaServer.sh
Executable file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Phase 1 of CommunityDiscordMedia.md: add Authentik (Discord login) + Jellyfin to a running
|
||||||
|
# vanguard-stream box. Asks for the two hostnames, checks DNS, writes configs/.env via configure.py,
|
||||||
|
# starts the containers, waits for them, then prints the Phase 2/3 setup steps with real hostnames.
|
||||||
|
# Run as the debian user from ~/vanguard-stream. Re-runnable.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
DRY="${VG_DRY:-0}"
|
||||||
|
|
||||||
|
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
|
||||||
|
ask() { local q="$1" d="${2:-}" a; read -r -p "$q${d:+ [$d]}: " a; echo "${a:-$d}"; }
|
||||||
|
yes() { local a; read -r -p "$1 [Y/n]: " a; [[ -z $a || $a =~ ^[Yy] ]]; }
|
||||||
|
ip_of(){ getent ahostsv4 "$1" 2>/dev/null | awk 'NR==1{print $1}' || true; }
|
||||||
|
|
||||||
|
# ---- preconditions ------------------------------------------------------------------------------
|
||||||
|
[[ -f configure.py && -f docker-compose.yml ]] || { echo "run from the vanguard-stream folder"; exit 1; }
|
||||||
|
[[ $EUID -ne 0 ]] || { echo "run as the debian user, not root (sudo is used where needed)"; exit 1; }
|
||||||
|
MAIN=$(python3 -c 'import configure as c; print(c.read_domain())')
|
||||||
|
[[ -n $MAIN ]] || { echo "main hostname not set — run ./configure.py --full first"; exit 1; }
|
||||||
|
if [[ $DRY != 1 ]]; then
|
||||||
|
docker info >/dev/null 2>&1 || { echo "docker not usable as this user — log out/in (docker group) or run install.sh"; exit 1; }
|
||||||
|
fi
|
||||||
|
read -r AUTH_CUR WATCH_CUR < <(python3 -c 'import configure as c; h=c.read_hosts(); print(h.get("auth",""), h.get("watch",""))')
|
||||||
|
BASE="${MAIN#*.}"; [[ $MAIN == *.*.* ]] || BASE="$MAIN"
|
||||||
|
|
||||||
|
say "Community media server — Phase 1 (Authentik + Jellyfin) on $MAIN"
|
||||||
|
echo "Needs two DNS names as CNAMEs to $MAIN. Create them now if you haven't."
|
||||||
|
AUTH=$(ask "Authentik hostname" "${AUTH_CUR:-auth.$BASE}")
|
||||||
|
WATCH=$(ask "Jellyfin hostname" "${WATCH_CUR:-watch.$BASE}")
|
||||||
|
GIT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("git",""))')
|
||||||
|
GIT=$(ask "Gitea hostname" "${GIT_CUR:-git.$BASE}")
|
||||||
|
BOT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("bot",""))')
|
||||||
|
BOT=$(ask "Bot web UI hostname" "${BOT_CUR:-bot.$BASE}")
|
||||||
|
|
||||||
|
# ---- DNS sanity ----------------------------------------------------------------------------------
|
||||||
|
say "DNS check"
|
||||||
|
MAIN_IP=$(ip_of "$MAIN"); ok=1
|
||||||
|
for h in "$AUTH" "$WATCH" "$GIT" "$BOT"; do
|
||||||
|
ip=$(ip_of "$h")
|
||||||
|
if [[ -z $ip ]]; then echo " $h: does not resolve yet"; ok=0
|
||||||
|
elif [[ $ip != "$MAIN_IP" ]]; then echo " $h -> $ip (main is $MAIN_IP) — points somewhere else"; ok=0
|
||||||
|
else echo " $h -> $ip ok"; fi
|
||||||
|
done
|
||||||
|
if [[ $ok == 0 ]]; then
|
||||||
|
echo " Let's Encrypt will fail for names that don't point here. Caddy retries on its own once DNS is right."
|
||||||
|
yes "continue anyway?" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- write configs, .env, start containers -----------------------------------------------------
|
||||||
|
say "Applying (configure.py --hosts)"
|
||||||
|
VG_DRY=$DRY python3 configure.py --hosts "$AUTH" "$WATCH" "$GIT" "$BOT" | sed -n '/== writing/,$p' | grep -vE '^ (techy|diceofire|[a-z0-9_-]+) password' || true
|
||||||
|
[[ $DRY == 1 ]] && { echo "(dry run: skipping container wait)"; exit 0; }
|
||||||
|
|
||||||
|
# ---- wait ---------------------------------------------------------------------------------------
|
||||||
|
say "Waiting for containers"
|
||||||
|
for c in authentik-db authentik-redis authentik-server authentik-worker jellyfin mariadb discord-bot gitea caddy; do
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
st=$(docker inspect -f '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$c" 2>/dev/null || echo missing)
|
||||||
|
case "$st" in running\ healthy|running\ ) echo " $c: $st"; break;; esac
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[[ $st == running* ]] || echo " $c: $st <- check: docker logs $c"
|
||||||
|
done
|
||||||
|
|
||||||
|
say "Waiting for HTTPS (certificates + Authentik migrations; up to ~3 min)"
|
||||||
|
for h in "$AUTH" "$WATCH" "$GIT"; do
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
code=$(curl -s -o /dev/null -m 5 -w '%{http_code}' "https://$h/" || true)
|
||||||
|
[[ $code =~ ^(200|302|301)$ ]] && { echo " https://$h/ $code"; break; }
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[[ $code =~ ^(200|302|301)$ ]] || echo " https://$h/ not answering yet (got '${code:-none}') — docker logs caddy / authentik-server"
|
||||||
|
done
|
||||||
|
echo; free -h | awk 'NR<=2{print " "$0}'
|
||||||
|
|
||||||
|
# ---- Vanguard Metadata plugin (optional: only if a built zip was uploaded with the bundle) ------------
|
||||||
|
if ls jellyfin-plugin-vanguard/dist/VanguardMetadata_*.zip >/dev/null 2>&1; then
|
||||||
|
say "Installing the Vanguard Metadata Jellyfin plugin"
|
||||||
|
bash installJellyfinPlugin.sh || echo " plugin install failed — see above; Jellyfin works without it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- instructions -------------------------------------------------------------------------------
|
||||||
|
cat <<MSG
|
||||||
|
|
||||||
|
$(printf '\033[1m== Phase 1 done. Next, by hand:\033[0m')
|
||||||
|
|
||||||
|
PHASE 2 — Authentik + Discord
|
||||||
|
1. https://$AUTH/if/flow/initial-setup/ -> set the akadmin password (keep it somewhere safe)
|
||||||
|
2. Discord developer portal -> New Application -> OAuth2 -> Redirects: add
|
||||||
|
https://$AUTH/source/oauth/callback/discord/
|
||||||
|
(the last path segment is the source SLUG you choose in step 3 — keep them identical)
|
||||||
|
copy Client ID and Client Secret
|
||||||
|
3. Authentik -> Directory -> Federation & Social login -> Create -> Discord OAuth Source
|
||||||
|
Name: Discord Slug: discord (must match the redirect above)
|
||||||
|
User matching: "Link users on unique identifier" (default; creates the user on first login)
|
||||||
|
Consumer key = Client ID, Consumer secret = Client Secret; Scopes: leave empty
|
||||||
|
Flow settings (if shown): authentication default-source-authentication,
|
||||||
|
enrollment default-source-enrollment
|
||||||
|
4. Put the source on the login page (a new source is NOT shown until you do this):
|
||||||
|
Flows & Stages -> Stages -> edit "default-authentication-identification"
|
||||||
|
-> Sources: select the Discord source; tick "Show sources' labels" -> Update
|
||||||
|
5. Make Discord users "Internal" (External users can't open the portal or admin UI):
|
||||||
|
Flows & Stages -> Stages -> edit "default-source-enrollment-write" -> User type: Internal -> Update
|
||||||
|
6. Directory -> Groups -> create: streamers, editors, admins
|
||||||
|
7. Open a private window -> https://$AUTH/ -> click "Discord" -> log in; confirm the user appears
|
||||||
|
under Directory -> Users (if it says External, edit it -> User type Internal); add yourself to admins
|
||||||
|
8. (optional) only members of YOUR Discord server may log in:
|
||||||
|
a. Discord -> User Settings -> Advanced -> Developer Mode; right-click your server -> Copy Server ID
|
||||||
|
b. edit the Discord source -> Protocol settings -> Scopes: guilds -> Update
|
||||||
|
c. Customization -> Policies -> Create -> Expression Policy, name discord-guild-member,
|
||||||
|
paste authentik/discord-guild-policy.py (set ACCEPTED_GUILD_ID and GUILD_NAME) -> Create
|
||||||
|
d. Flows & Stages -> Flows -> default-source-enrollment -> "Policy / Group / User Bindings"
|
||||||
|
-> Bind existing policy -> discord-guild-member. Repeat for default-source-authentication.
|
||||||
|
Then edit each of those two flows -> Policy engine mode: all (default "any" would let the
|
||||||
|
existing if-sso binding alone pass the login; keep that binding, just switch the mode).
|
||||||
|
e. test: private window, Discord login with a member (works) and a non-member (denied with message)
|
||||||
|
Users who logged in before (b) get a one-time Discord re-consent for the new scope.
|
||||||
|
9. pin the version: edit .env -> AUTHENTIK_TAG=<the tag now running> (docker inspect authentik-server)
|
||||||
|
|
||||||
|
PHASE 3 — Jellyfin (direct play only on this CPU)
|
||||||
|
1. https://$WATCH/ -> wizard: admin user; libraries:
|
||||||
|
/media/streams and /media/clips -> type "Shows": metadata downloaders = ONLY "Vanguard Metadata"
|
||||||
|
(Series, Season, Episode); image fetchers = Vanguard Metadata (Series) + Screen Grabber (Episode);
|
||||||
|
real-time monitoring on
|
||||||
|
/media/finals -> type "Home Videos", all metadata OFF
|
||||||
|
(the Vanguard plugin is installed by this script when its zip is present; it needs no config to
|
||||||
|
do seasons — Discord/Authentik fields are optional and add names, avatars, event titles, the feed)
|
||||||
|
then Dashboard -> Scheduled Tasks -> run "Vanguard: roll seasons" once after the first scan
|
||||||
|
2. Dashboard -> Playback -> Transcoding: hardware acceleration = none
|
||||||
|
Users -> each user -> Playback: untick "Allow video playback that requires transcoding"
|
||||||
|
3. Dashboard -> Plugins -> Repositories -> "+" -> name SSO-Auth, URL:
|
||||||
|
https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/manifest-release/manifest.json
|
||||||
|
-> Save; Catalog tab -> "SSO Authentication" -> Install -> docker compose restart jellyfin
|
||||||
|
4. Authentik -> Applications -> Create with provider -> OAuth2/OpenID
|
||||||
|
name: jellyfin; redirect URI: https://$WATCH/sso/OID/redirect/authentik
|
||||||
|
note the client ID / secret and the provider's slug
|
||||||
|
5. Jellyfin -> Plugins -> SSO-Auth -> OID providers -> add "authentik":
|
||||||
|
OID endpoint: https://$AUTH/application/o/<slug>/
|
||||||
|
client ID / secret; Enabled; Roles claim: groups; Admin roles: admins;
|
||||||
|
Enabled folders: the three libraries; default "disable transcoding" on
|
||||||
|
Scheme Override: https Port Override: EMPTY (a value adds ":443" to the redirect -> mismatch)
|
||||||
|
-> Save at the bottom of the page (unsaved providers give "Provider does not exist")
|
||||||
|
6. test the plugin: open https://$WATCH/sso/OID/start/authentik -> Authentik -> back in, logged in.
|
||||||
|
Always start from the /start/ URL; opening the /redirect/ URL by hand gives "missing state".
|
||||||
|
7. login page = Discord button first, local form behind an "Admin login" link:
|
||||||
|
Dashboard -> General -> Branding
|
||||||
|
Login disclaimer: paste jellyfin/branding-disclaimer.html (replace watch.HOST with $WATCH)
|
||||||
|
Custom CSS: paste jellyfin/branding.css
|
||||||
|
-> Save; reload https://$WATCH/
|
||||||
|
|
||||||
|
BOT WEB UI (https://$BOT/) — Authentik forward-auth gate, create once:
|
||||||
|
Authentik -> Applications -> Create with provider -> Proxy Provider
|
||||||
|
mode "Forward auth (single application)", External host https://$BOT
|
||||||
|
-> Applications -> Outposts -> edit "authentik Embedded Outpost" -> add the new application -> Update
|
||||||
|
-> policy on the application: groups admins / editors / streamers as you see fit
|
||||||
|
Until this exists the site returns an error (never open). The bot receives X-Authentik-Username/-Groups/-Email.
|
||||||
|
|
||||||
|
GITEA (git.$GIT)
|
||||||
|
1. https://$GIT/ -> the first visit shows the install page: database is pre-filled from env; set the
|
||||||
|
admin account at the bottom (registration is closed afterwards) -> Install Gitea
|
||||||
|
2. ./registerGiteaRunner.sh -> registers "vanguard-runner"; Site Administration -> Actions -> Runners
|
||||||
|
3. users: Site Administration -> Identity & Access -> User Accounts -> Create (or Authentik OIDC later)
|
||||||
|
4. git remotes: ssh://git@$GIT:2222/<org>/<repo>.git or https://$GIT/<org>/<repo>.git
|
||||||
|
|
||||||
|
Then continue with Phase 4 in CommunityDiscordMedia.md (the Vanguard Metadata Jellyfin plugin).
|
||||||
|
MSG
|
||||||
32
authentik/discord-guild-policy.py
Normal file
32
authentik/discord-guild-policy.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Authentik expression policy: allow Discord logins only for members of one Discord server (guild).
|
||||||
|
# Paste into: Customization -> Policies -> Create -> Expression Policy (name: discord-guild-member)
|
||||||
|
# Bind to BOTH flows: default-source-enrollment and default-source-authentication
|
||||||
|
# (Flows & Stages -> Flows -> <flow> -> Policy / Group / User Bindings -> Bind existing policy)
|
||||||
|
# Requires the "guilds" scope on the Discord source (source -> Protocol settings -> Scopes: guilds).
|
||||||
|
#
|
||||||
|
# Server ID: Discord -> User Settings -> Advanced -> Developer Mode on; right-click the server -> Copy Server ID.
|
||||||
|
|
||||||
|
ACCEPTED_GUILD_ID = "1387203044084551690" # <- your Discord server ID
|
||||||
|
GUILD_NAME = "Vanguard" # <- shown in the denial message
|
||||||
|
|
||||||
|
GUILD_API_URL = "https://discord.com/api/users/@me/guilds"
|
||||||
|
|
||||||
|
# Only act on logins that came through the Discord source; let everything else (akadmin password login) pass.
|
||||||
|
source = context.get("source")
|
||||||
|
if not source or getattr(source, "provider_type", "") != "discord":
|
||||||
|
return True
|
||||||
|
|
||||||
|
connection = context.get("goauthentik.io/sources/connection")
|
||||||
|
if not connection:
|
||||||
|
ak_message("No Discord connection in context.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
resp = requests.get(GUILD_API_URL, headers={"Authorization": f"Bearer {connection.access_token}"}, timeout=10)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
ak_message(f"Could not read Discord server list ({resp.status_code}). Try again.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_member = any(g.get("id") == ACCEPTED_GUILD_ID for g in resp.json())
|
||||||
|
if not is_member:
|
||||||
|
ak_message(f"You must be a member of the {GUILD_NAME} Discord server to log in.")
|
||||||
|
return is_member
|
||||||
167
bin/dumpd.py
Executable file
167
bin/dumpd.py
Executable file
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dump-tonight endpoint. Moves one streamer's night into trash (soft delete).
|
||||||
|
|
||||||
|
POST /dump Authorization: Basic user:pass body (optional JSON): {"night":"YYYY-MM-DD","user":"name"}
|
||||||
|
GET /dump -> lists what a dump would move (dry run)
|
||||||
|
|
||||||
|
"Night" = everything the user recorded from 17:00 Eastern on <night> until 02:00 Eastern the next day.
|
||||||
|
Default night: today if it is 17:00 or later, else yesterday (so 00:00-02:00 still counts as last night).
|
||||||
|
Users may dump only their own prefix; 'admin' may pass "user" to dump anyone, or "all".
|
||||||
|
"""
|
||||||
|
import base64, hashlib, json, os, shutil, sys
|
||||||
|
from datetime import datetime, timedelta, time
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(os.environ.get("MEDIA_ROOT", "/srv/media"))
|
||||||
|
SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
|
||||||
|
TRASH = ROOT / "trash"
|
||||||
|
MARK = ROOT / ".markers"
|
||||||
|
USERS_FILE = Path(os.environ.get("DUMP_USERS", "/etc/vanguard/dump-users.txt"))
|
||||||
|
LISTEN = ("127.0.0.1", 8080)
|
||||||
|
TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
|
||||||
|
NIGHT_START = time(17, 0) # 5 pm
|
||||||
|
NIGHT_END = time(2, 0) # 2 am next day
|
||||||
|
|
||||||
|
def load_users():
|
||||||
|
users = {}
|
||||||
|
for line in USERS_FILE.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or ":" not in line:
|
||||||
|
continue
|
||||||
|
u, h = line.split(":", 1)
|
||||||
|
users[u.strip()] = h.strip().lower()
|
||||||
|
return users
|
||||||
|
|
||||||
|
def check_auth(header):
|
||||||
|
if not header or not header.startswith("Basic "):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(header[6:]).decode()
|
||||||
|
u, p = raw.split(":", 1)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
users = load_users()
|
||||||
|
if users.get(u) == hashlib.sha256(p.encode()).hexdigest():
|
||||||
|
return u
|
||||||
|
return None
|
||||||
|
|
||||||
|
def default_night(now=None):
|
||||||
|
now = now or datetime.now(TZ)
|
||||||
|
d = now.date() if now.time() >= NIGHT_START else (now - timedelta(days=1)).date()
|
||||||
|
return d.isoformat()
|
||||||
|
|
||||||
|
def night_window(night):
|
||||||
|
d = datetime.strptime(night, "%Y-%m-%d").date()
|
||||||
|
return (datetime.combine(d, NIGHT_START, tzinfo=TZ),
|
||||||
|
datetime.combine(d + timedelta(days=1), NIGHT_END, tzinfo=TZ))
|
||||||
|
|
||||||
|
def candidates(user, night):
|
||||||
|
start, end = night_window(night)
|
||||||
|
out = []
|
||||||
|
for base in SRC_DIRS:
|
||||||
|
udirs = [p for p in base.iterdir() if p.is_dir()] if user == "all" else [base / user]
|
||||||
|
for ud in udirs:
|
||||||
|
if not ud.is_dir():
|
||||||
|
continue
|
||||||
|
for f in ud.rglob("*"):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
mt = datetime.fromtimestamp(f.stat().st_mtime, TZ)
|
||||||
|
if start <= mt <= end:
|
||||||
|
out.append(f)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def do_move(files, night):
|
||||||
|
moved, size = 0, 0
|
||||||
|
for f in files:
|
||||||
|
rel = f.relative_to(ROOT)
|
||||||
|
dst = TRASH / night / rel
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
size += f.stat().st_size
|
||||||
|
shutil.move(str(f), str(dst))
|
||||||
|
moved += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
# drop now-empty title folders
|
||||||
|
for base in SRC_DIRS:
|
||||||
|
for d in sorted((p for p in base.rglob("*") if p.is_dir()), key=lambda p: -len(p.parts)):
|
||||||
|
if d.parent != base and not any(d.iterdir()):
|
||||||
|
d.rmdir()
|
||||||
|
return moved, size
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
server_version = "dumpd/1"
|
||||||
|
def _json(self, code, obj):
|
||||||
|
body = json.dumps(obj).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
def _deny(self):
|
||||||
|
self.send_response(401)
|
||||||
|
self.send_header("WWW-Authenticate", 'Basic realm="dump"')
|
||||||
|
self.end_headers()
|
||||||
|
def _parse(self):
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(self.rfile.read(n) or b"{}") if n else {}
|
||||||
|
night = body.get("night")
|
||||||
|
if night:
|
||||||
|
datetime.strptime(night, "%Y-%m-%d") # validate
|
||||||
|
return night, body.get("user")
|
||||||
|
def _resolve(self, night, tgt):
|
||||||
|
"""Explicit night wins. Otherwise: tonight, falling back to last night if tonight is empty."""
|
||||||
|
if night:
|
||||||
|
return night, candidates(tgt, night)
|
||||||
|
n = default_night()
|
||||||
|
files = candidates(tgt, n)
|
||||||
|
if not files:
|
||||||
|
prev = (datetime.strptime(n, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
pf = candidates(tgt, prev)
|
||||||
|
if pf:
|
||||||
|
return prev, pf
|
||||||
|
return n, files
|
||||||
|
def _target(self, auth_user, req_user):
|
||||||
|
if auth_user == "admin":
|
||||||
|
return req_user or "all"
|
||||||
|
return auth_user
|
||||||
|
def do_GET(self):
|
||||||
|
if not self.path.startswith("/dump"):
|
||||||
|
return self._json(404, {"error": "not found"})
|
||||||
|
u = check_auth(self.headers.get("Authorization"))
|
||||||
|
if not u:
|
||||||
|
return self._deny()
|
||||||
|
night, req_user = self._parse()
|
||||||
|
tgt = self._target(u, req_user)
|
||||||
|
night, files = self._resolve(night, tgt)
|
||||||
|
self._json(200, {"night": night, "user": tgt, "dry_run": True,
|
||||||
|
"files": [str(f.relative_to(ROOT)) for f in files],
|
||||||
|
"gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)})
|
||||||
|
def do_POST(self):
|
||||||
|
if not self.path.startswith("/dump"):
|
||||||
|
return self._json(404, {"error": "not found"})
|
||||||
|
u = check_auth(self.headers.get("Authorization"))
|
||||||
|
if not u:
|
||||||
|
return self._deny()
|
||||||
|
try:
|
||||||
|
night, req_user = self._parse()
|
||||||
|
except Exception as e:
|
||||||
|
return self._json(400, {"error": str(e)})
|
||||||
|
tgt = self._target(u, req_user)
|
||||||
|
night, files = self._resolve(night, tgt)
|
||||||
|
moved, size = do_move(files, night)
|
||||||
|
MARK.mkdir(exist_ok=True)
|
||||||
|
(MARK / f"{night}-{tgt}.dumped").write_text(f"{datetime.now(TZ).isoformat()} by {u}\n")
|
||||||
|
print(f"{datetime.now(TZ):%F %T} dump night={night} user={tgt} by={u} files={moved} gb={size/1e9:.2f}", flush=True)
|
||||||
|
self._json(200, {"night": night, "user": tgt, "moved": moved, "gb": round(size / 1e9, 2),
|
||||||
|
"undo_until": (datetime.now(TZ) + timedelta(days=3)).strftime("%F")})
|
||||||
|
def log_message(self, *a): # quiet
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
TRASH.mkdir(exist_ok=True); MARK.mkdir(exist_ok=True)
|
||||||
|
print(f"dumpd listening on {LISTEN[0]}:{LISTEN[1]} tz={TZ.key} night={NIGHT_START:%H:%M}-{NIGHT_END:%H:%M}", flush=True)
|
||||||
|
ThreadingHTTPServer(LISTEN, H).serve_forever()
|
||||||
128
bin/normalize-sweep.sh
Executable file
128
bin/normalize-sweep.sh
Executable file
@@ -0,0 +1,128 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Normalize every finished original so editors always get the same kind of file:
|
||||||
|
# MP4 · H.264 8-bit 4:2:0 · constant frame rate · AAC audio · index up front · ≤2560x1440 · ≤60 fps
|
||||||
|
# - compliant files: untouched
|
||||||
|
# - fragmented/unindexed MP4 (MediaMTX output) with good codecs: lossless remux, replaced in place
|
||||||
|
# - anything else (HEVC, 10-bit, VFR, MKV, >1440p, >60fps): full-res re-encode -> name.mp4,
|
||||||
|
# raw source kept in a hidden .src/ subfolder next to it (Jellyfin/copyparty hide dot-dirs)
|
||||||
|
# - Syncthing folders (marker dir .stfolder in the folder root, receive-only): compliant files are left
|
||||||
|
# alone (zero copies); a non-compliant file is converted to name.mp4 (name.h264.mp4 if the raw is
|
||||||
|
# already .mp4), the raw's name is appended to .stlocal (an #include'd ignore list) so Syncthing
|
||||||
|
# drops it without flagging or re-pulling, then the raw is deleted. One copy on the server.
|
||||||
|
# - failure: name.normalize-failed.log is written and the file is skipped until that log is deleted
|
||||||
|
# Idempotent; safe every few minutes.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT="${MEDIA_ROOT:-/srv/media}"
|
||||||
|
MINS_IDLE=3
|
||||||
|
MAX_W=2560; MAX_H=1440; MAX_FPS=60
|
||||||
|
exec 9>"${LOCK_FILE:-/run/lock/normalize-sweep.lock}"
|
||||||
|
flock -n 9 || exit 0
|
||||||
|
|
||||||
|
log() { echo "$(date '+%F %T') $*"; }
|
||||||
|
dur() { ffprobe -v error -show_entries format=duration -of csv=p=0 "$1" 2>/dev/null | cut -d. -f1; }
|
||||||
|
|
||||||
|
# --- probe: prints "verdict reason" where verdict ∈ ok|remux|encode|skip ----------------------
|
||||||
|
probe() {
|
||||||
|
local f="$1" ext="${1##*.}"
|
||||||
|
local json; json=$(ffprobe -v error -show_entries stream=codec_type,codec_name,pix_fmt,width,height,r_frame_rate,avg_frame_rate -of json "$f" 2>/dev/null) || { echo "skip unreadable"; return; }
|
||||||
|
local vcodec pix w h rfr afr acodecs
|
||||||
|
vcodec=$(jq -r '[.streams[]|select(.codec_type=="video")][0].codec_name // "none"' <<<"$json")
|
||||||
|
pix=$(jq -r '[.streams[]|select(.codec_type=="video")][0].pix_fmt // ""' <<<"$json")
|
||||||
|
w=$(jq -r '[.streams[]|select(.codec_type=="video")][0].width // 0' <<<"$json")
|
||||||
|
h=$(jq -r '[.streams[]|select(.codec_type=="video")][0].height // 0' <<<"$json")
|
||||||
|
rfr=$(jq -r '[.streams[]|select(.codec_type=="video")][0].r_frame_rate // "0/1"' <<<"$json")
|
||||||
|
afr=$(jq -r '[.streams[]|select(.codec_type=="video")][0].avg_frame_rate // "0/1"' <<<"$json")
|
||||||
|
acodecs=$(jq -r '[.streams[]|select(.codec_type=="audio")|.codec_name]|unique|join(",")' <<<"$json")
|
||||||
|
[[ $vcodec == none ]] && { echo "skip no-video"; return; }
|
||||||
|
local fps; fps=$(awk -F/ '{ if ($2>0) printf "%.3f", $1/$2; else print 0 }' <<<"$afr")
|
||||||
|
local reasons=()
|
||||||
|
[[ $vcodec == h264 ]] || reasons+=("codec=$vcodec")
|
||||||
|
[[ $pix == yuv420p ]] || reasons+=("pix=$pix")
|
||||||
|
(( w <= MAX_W && h <= MAX_H )) || reasons+=("size=${w}x${h}")
|
||||||
|
awk "BEGIN{exit !($fps > $MAX_FPS + 0.01)}" && reasons+=("fps=$fps")
|
||||||
|
[[ -z $acodecs || $acodecs == aac ]] || reasons+=("audio=$acodecs")
|
||||||
|
[[ ${ext,,} == mp4 ]] || reasons+=("container=$ext")
|
||||||
|
if (( ${#reasons[@]} )); then echo "encode ${reasons[*]}"; return; fi
|
||||||
|
# codecs fine. Fragmented MP4 (MediaMTX output) -> lossless remux. ffprobe's frame-rate figures are
|
||||||
|
# meaningless on fragmented files, so the VFR check only runs on flat files (i.e. after the remux).
|
||||||
|
local head; head=$(head -c 2097152 "$f" | grep -aob -m3 -E 'moov|mdat|moof' | tr '\n' ' ')
|
||||||
|
if grep -q moof <<<"$head"; then echo "remux fragmented"; return; fi
|
||||||
|
# real VFR test: do the gaps between the first ~400 frames vary? (dts is monotonic even with B-frames)
|
||||||
|
local vfr; vfr=$(ffprobe -v error -select_streams v:0 -show_entries packet=dts_time -of csv=p=0 -read_intervals '%+#400' "$f" 2>/dev/null |
|
||||||
|
awk 'NF && $1!="N/A" { if (p!="") d[n++]=$1-p; p=$1 }
|
||||||
|
END { if (n<20) { print "cfr"; exit }
|
||||||
|
for(i=0;i<n;i++) a[i]=d[i]; asort(a); med=a[int(n/2)]; if (med<=0) { print "cfr"; exit }
|
||||||
|
bad=0; for(i=0;i<n;i++){ x=d[i]-med; if(x<0)x=-x; if (x > med*0.25) bad++ }
|
||||||
|
print (bad > n*0.02) ? "vfr" : "cfr" }')
|
||||||
|
[[ $vfr == vfr ]] && { echo "encode vfr"; return; }
|
||||||
|
local moov mdat; moov=$(grep -o '[0-9]*:moov' <<<"$head" | cut -d: -f1 | head -1); mdat=$(grep -o '[0-9]*:mdat' <<<"$head" | cut -d: -f1 | head -1)
|
||||||
|
if [[ -z $moov || ( -n $mdat && $mdat -lt $moov ) ]]; then echo "remux index-last"; return; fi
|
||||||
|
echo "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
find "$ROOT/streams" "$ROOT/clips" -type f \
|
||||||
|
\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.mov' -o -iname '*.ts' -o -iname '*.m4v' -o -iname '*.webm' \) \
|
||||||
|
! -path '*/.src/*' ! -path '*/.stfolder/*' ! -path '*/.stversions/*' ! -name '*.part' -mmin +"$MINS_IDLE" -print0 2>/dev/null |
|
||||||
|
while IFS= read -r -d '' src; do
|
||||||
|
base="${src%.*}"; ext="${src##*.}"
|
||||||
|
synced=0; stroot=""
|
||||||
|
d="$(dirname "$src")"
|
||||||
|
while [[ $d == "$ROOT"/* ]]; do # find the Syncthing folder root, if any
|
||||||
|
if [[ -d "$d/.stfolder" ]]; then synced=1; stroot="$d"; break; fi
|
||||||
|
d="$(dirname "$d")"
|
||||||
|
done
|
||||||
|
[[ -e "$base.normalize-failed.log" ]] && continue
|
||||||
|
fuser -s "$src" 2>/dev/null && continue # still being written
|
||||||
|
[[ -e "$src" ]] || continue # dumped meanwhile
|
||||||
|
|
||||||
|
read -r verdict reason <<<"$(probe "$src")"
|
||||||
|
case "$verdict" in ok|skip) continue ;; esac
|
||||||
|
out="$base.mp4"
|
||||||
|
if (( synced )); then
|
||||||
|
[[ $verdict == remux ]] && continue # synced + only an index issue: not worth touching
|
||||||
|
[[ ${ext,,} == mp4 ]] && out="$base.h264.mp4" # raw is already name.mp4: don't collide
|
||||||
|
[[ -e "$out" ]] && continue
|
||||||
|
fi
|
||||||
|
tmp="$out.part"
|
||||||
|
log "$verdict $src ($reason)"
|
||||||
|
if [[ $verdict == remux ]]; then
|
||||||
|
nice -n 15 ionice -c 3 ffmpeg -nostdin -hide_banner -loglevel error -y -i "$src" \
|
||||||
|
-map 0 -c copy -movflags +faststart -f mp4 "$tmp"
|
||||||
|
else
|
||||||
|
# scale down into the cap (never up), keep aspect, even dimensions; cap fps; CFR; all audio -> AAC
|
||||||
|
vf="scale='min(iw,$MAX_W)':'min(ih,$MAX_H)':force_original_aspect_ratio=decrease:force_divisible_by=2"
|
||||||
|
fpsargs=(); fpsnow=$(awk -F/ '{ if ($2>0) printf "%.3f", $1/$2; else print 0 }' <<<"$(ffprobe -v error -select_streams v:0 -show_entries stream=avg_frame_rate -of csv=p=0 "$src")")
|
||||||
|
awk "BEGIN{exit !($fpsnow > $MAX_FPS + 0.01)}" && fpsargs=(-r "$MAX_FPS")
|
||||||
|
nice -n 15 ionice -c 3 ffmpeg -nostdin -hide_banner -loglevel error -y -i "$src" \
|
||||||
|
-map 0:v:0 -map '0:a?' \
|
||||||
|
-c:v libx264 -preset veryfast -crf 18 -maxrate 30M -bufsize 60M -pix_fmt yuv420p -profile:v high \
|
||||||
|
-vf "$vf" -fps_mode cfr "${fpsargs[@]}" \
|
||||||
|
-c:a aac -b:a 160k \
|
||||||
|
-movflags +faststart -f mp4 "$tmp"
|
||||||
|
fi
|
||||||
|
rc=$?
|
||||||
|
d1=$(dur "$src"); d2=$(dur "$tmp")
|
||||||
|
if (( rc == 0 )) && [[ -n $d1 && -n $d2 ]] && (( d2 >= d1 - 2 && d2 <= d1 + 2 )); then
|
||||||
|
if (( synced )); then
|
||||||
|
rel_src="${src#"$stroot"/}"; rel_out="${out#"$stroot"/}"
|
||||||
|
{ echo "/$rel_out"; echo "/$rel_src"; } >> "$stroot/.stlocal" # ignore our output and the raw
|
||||||
|
chown 1000:1000 "$stroot/.stlocal" 2>/dev/null
|
||||||
|
sleep 2 # let Syncthing re-read ignores before the delete
|
||||||
|
mv -f "$tmp" "$out"
|
||||||
|
rm -f "$src" # raw stays on the gamer's PC; one copy here
|
||||||
|
elif [[ $verdict == remux ]]; then
|
||||||
|
mv -f "$tmp" "$src" # lossless: replace in place
|
||||||
|
else
|
||||||
|
srcdir="$(dirname "$src")/.src"; mkdir -p "$srcdir"; chown 1000:1000 "$srcdir" 2>/dev/null
|
||||||
|
mv -f "$src" "$srcdir/$(basename "$src")" # keep raw source, hidden from editors/Jellyfin
|
||||||
|
mv -f "$tmp" "$out"
|
||||||
|
fi
|
||||||
|
chown 1000:1000 "$out" 2>/dev/null
|
||||||
|
log "done $out"
|
||||||
|
else
|
||||||
|
rm -f "$tmp"
|
||||||
|
{ echo "$(date '+%F %T') $verdict failed rc=$rc src_dur=${d1:-?} out_dur=${d2:-?} ($reason)"; } > "$base.normalize-failed.log"
|
||||||
|
log "FAILED $src (see $base.normalize-failed.log)" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
10
bin/trash-purge.sh
Executable file
10
bin/trash-purge.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Delete dumped nights older than 3 days; drop empty dirs; clean stale markers.
|
||||||
|
set -uo pipefail
|
||||||
|
TRASH=/srv/media/trash
|
||||||
|
MARK=/srv/media/.markers
|
||||||
|
[[ -d $TRASH ]] || exit 0
|
||||||
|
find "$TRASH" -mindepth 1 -type f -mtime +3 -delete
|
||||||
|
find "$TRASH" -mindepth 1 -type d -empty -delete
|
||||||
|
find "$MARK" -type f -mtime +30 -delete 2>/dev/null
|
||||||
|
echo "$(date '+%F %T') trash purge done"
|
||||||
2
bot/.dockerignore
Normal file
2
bot/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
app/__pycache__/
|
||||||
|
app/*.pyc
|
||||||
108
bot/AGENT.md
Normal file
108
bot/AGENT.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Vanguard Discord bot — brief for the agent building it
|
||||||
|
|
||||||
|
You are building the Discord bot for Vanguard. What the bot *does* is up to the community and you.
|
||||||
|
This file only covers where it runs, what it is given, and the few rules that keep it from breaking
|
||||||
|
the box it shares. Nothing here obliges you to integrate with the other services on that box.
|
||||||
|
|
||||||
|
## 1. Hosting
|
||||||
|
|
||||||
|
One Debian server runs a set of Docker Compose services from `~/vanguard-stream/`. You own exactly
|
||||||
|
one of them: the `discord-bot` container. You do not own the compose file, the reverse proxy (Caddy),
|
||||||
|
the firewall, the identity provider (Authentik), or the database server config. If you need any of
|
||||||
|
those changed, ask the admin with the exact change (see §6).
|
||||||
|
|
||||||
|
What you are given:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Runtime** | `python:3.12-slim` + ffmpeg + curl. Dependencies from `app/requirements.txt`, installed at image build (`docker compose build discord-bot`). |
|
||||||
|
| **Your code** | bind-mounted at `/app` from `bot/app/`. Not baked into the image. Edit, then `docker compose restart discord-bot`. |
|
||||||
|
| **Entry point** | the container runs `python -u /app/main.py` and restarts it if it exits. With no `main.py` it idles. |
|
||||||
|
| **User** | uid 1000 |
|
||||||
|
| **Database** | MariaDB, database `vanguardbot`, user `bot`, reachable at `DB_HOST:DB_PORT`. Yours alone. Other databases on the same server belong to other services; never touch them. |
|
||||||
|
| **Persistent disk** | `/data`, yours |
|
||||||
|
| **Web UI** | bind `WEB_HOST:WEB_PORT` (0.0.0.0:8000). The proxy publishes it as `https://bot.<domain>/` behind login (§3). |
|
||||||
|
| **Discord** | `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`. The bot user belongs to the community's existing Discord application. |
|
||||||
|
| **Clock** | `TZ=America/New_York` |
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
`DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`,
|
||||||
|
`WEB_HOST`, `WEB_PORT`, `WEB_PUBLIC_URL`, `AUTH_HEADER_USER`, `AUTH_HEADER_GROUPS`, `AUTH_HEADER_EMAIL`,
|
||||||
|
`MEDIA_CLIPS`, `TZ`. Secrets arrive only through env. Never write them to the repo, `/data`, or logs.
|
||||||
|
|
||||||
|
SQLAlchemy URL: `mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}` (async: `mysql+aiomysql`).
|
||||||
|
|
||||||
|
## 2. Process model
|
||||||
|
|
||||||
|
- One process, one asyncio loop: the Discord gateway client and the web server run together
|
||||||
|
(`discord.py` plus `aiohttp` or FastAPI via uvicorn's programmatic API). Do not spawn a second container.
|
||||||
|
- Log to stdout. The admin reads `docker compose logs -f discord-bot`.
|
||||||
|
- On fatal misconfiguration (no token, DB unreachable after retries) log the reason and exit non-zero;
|
||||||
|
the restart policy retries.
|
||||||
|
- Handle SIGTERM: close the gateway and the web server cleanly.
|
||||||
|
|
||||||
|
## 3. Web UI: login is handled outside your code
|
||||||
|
|
||||||
|
Every request to `https://bot.<domain>/` passes through Caddy, which sends the user to Authentik
|
||||||
|
(Discord login) and, once authenticated, forwards the request to you with identity headers:
|
||||||
|
|
||||||
|
| header | content |
|
||||||
|
|---|---|
|
||||||
|
| `X-Authentik-Username` | the user's account name |
|
||||||
|
| `X-Authentik-Groups` | group names, separated by `\|` or `,` — the community uses `admins`, `editors`, `streamers`; others may be added |
|
||||||
|
| `X-Authentik-Email`, `X-Authentik-Name`, `X-Authentik-Uid` | extras |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. **Implement no login page, no sessions, no passwords.** Identity is the headers, per request.
|
||||||
|
2. Port 8000 is loopback-only on the host, so the headers can be trusted. Still fail closed: a request
|
||||||
|
without the username header gets 401.
|
||||||
|
3. Authorize by group, read from the header each time. Don't cache roles in your DB.
|
||||||
|
4. Serve under `/`; the proxy does no path rewriting for this host.
|
||||||
|
5. Build absolute links from `WEB_PUBLIC_URL`.
|
||||||
|
6. The Authentik username may differ from the Discord username. Use the header value as the user key;
|
||||||
|
if you need the Discord snowflake, resolve it through your gateway client and cache it in your DB.
|
||||||
|
|
||||||
|
Until the admin creates the matching Authentik application, the hostname returns an error, never an
|
||||||
|
open page. Test against `http://127.0.0.1:8000/` on the server with the headers set by hand.
|
||||||
|
|
||||||
|
## 4. Repository layout (recommended, not enforced)
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/
|
||||||
|
AGENT.md, README.md this brief; the ops cheat-sheet (keep README current)
|
||||||
|
Dockerfile, entrypoint.sh admin-owned; ask before changing
|
||||||
|
app/
|
||||||
|
main.py config → migrations → gateway + web in one loop → clean shutdown
|
||||||
|
requirements.txt pin versions once past the prototype
|
||||||
|
config.py the only module that reads os.environ
|
||||||
|
bot/ discord.py client, cogs/ (one feature per cog), permission checks
|
||||||
|
web/ routes/, templates/, static/, auth.py (header → user/groups)
|
||||||
|
db/ SQLAlchemy models, session, alembic/ migrations (run at startup)
|
||||||
|
services/ the actual logic, shared by cogs and web; no Discord or HTTP types
|
||||||
|
tests/ pytest; must pass offline with no token
|
||||||
|
```
|
||||||
|
Slash commands scoped to the guild so they appear instantly. Prefer slash commands over reading
|
||||||
|
message content; Message Content is a privileged intent, enable it only if you need it.
|
||||||
|
|
||||||
|
## 5. Shared ground on the box
|
||||||
|
|
||||||
|
- `/media/clips` is mounted read-write. It is the community's clip storage, organized as one folder per
|
||||||
|
user. You may **add** files there; never modify, move or delete files you did not create (parts of it
|
||||||
|
are mirrored from members' machines and will fight back). If you write a video, write
|
||||||
|
`<name>.part` then rename to `<name>.mp4`, and put a `<name>.json` beside it with at least
|
||||||
|
`{"timestamp": "<UTC ISO-8601 creation time>", "source": "...", "uploader": "<discord username>"}`.
|
||||||
|
Other services on the box index that folder and read the sidecar. If you never touch this mount,
|
||||||
|
none of this applies.
|
||||||
|
- The same bot user is used by another service for REST calls (posting notices, reading scheduled
|
||||||
|
events). Sharing a token is fine. Don't rename the bot or change its avatar; pick your own channels.
|
||||||
|
- Don't bind other ports, don't start other containers, don't write outside `/app`, `/data`, `/media/clips`.
|
||||||
|
|
||||||
|
## 6. Working with the admin
|
||||||
|
|
||||||
|
- **Source**: push to the community Gitea, `ssh://git@git.<domain>:2222/vanguard/discord-bot.git`.
|
||||||
|
The admin deploys by pulling into `bot/app` and restarting. CI on the `python` runner label runs your tests.
|
||||||
|
- **Need infra?** Open a Gitea issue titled `infra: …` stating the exact change: a new env var and why,
|
||||||
|
a mount, a Caddy path that should skip login (e.g. a public webhook endpoint), a new database.
|
||||||
|
Don't work around the proxy or the firewall.
|
||||||
|
- **Dependencies changed?** Say so in the PR; the image must be rebuilt.
|
||||||
|
- Keep `README.md` updated with every command, env var, and table you add.
|
||||||
13
bot/Dockerfile
Normal file
13
bot/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Python runtime for the Discord bot. Source is bind-mounted at /app from ./bot/app (not baked in),
|
||||||
|
# so the dev edits code and restarts the container. Dependencies come from app/requirements.txt and
|
||||||
|
# are installed at BUILD time: after changing it run docker compose build discord-bot && docker compose up -d discord-bot
|
||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY app/requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install -r /tmp/requirements.txt
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
WORKDIR /app
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
43
bot/README.md
Normal file
43
bot/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# discord-bot — for the bot dev
|
||||||
|
|
||||||
|
Start with **AGENT.md** in this folder: the stack, the rules, the layout, the web-UI auth contract.
|
||||||
|
|
||||||
|
Your code goes in this folder (`bot/app/`). The container runs `python -u /app/main.py` and restarts
|
||||||
|
it if it exits. No `main.py` yet → the container idles and logs a hint.
|
||||||
|
|
||||||
|
## Day to day (on the server, in ~/vanguard-stream)
|
||||||
|
docker compose logs -f discord-bot # live log
|
||||||
|
docker compose restart discord-bot # after editing code
|
||||||
|
docker compose build discord-bot && docker compose up -d discord-bot # after editing requirements.txt
|
||||||
|
docker compose exec discord-bot python # a REPL inside the container
|
||||||
|
|
||||||
|
Edit files directly in `~/vanguard-stream/bot/app/` (they are bind-mounted) or scp them up.
|
||||||
|
|
||||||
|
## Environment the container gives you
|
||||||
|
| var | value |
|
||||||
|
|-----|-------|
|
||||||
|
| `DISCORD_BOT_TOKEN` | from `.env` (bundle root) — the Bot added to the existing Authentik Discord application |
|
||||||
|
| `DISCORD_GUILD_ID` | the server |
|
||||||
|
| `DB_HOST` / `DB_PORT` | `host.docker.internal` / `3306` (MariaDB runs on the host network) |
|
||||||
|
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | your own database + user (`vanguardbot` / `bot` / generated) |
|
||||||
|
| `MEDIA_CLIPS` | `/media/clips` — rw mount of `/srv/media/clips` (folder per user, for clip submissions) |
|
||||||
|
| `/data` | persistent scratch dir owned by the bot |
|
||||||
|
| `WEB_HOST` / `WEB_PORT` | `0.0.0.0` / `8000` — your web UI; public as `https://bot.<domain>/` via Caddy + Authentik |
|
||||||
|
| `WEB_PUBLIC_URL` | `https://bot.<domain>` for absolute links |
|
||||||
|
| `AUTH_HEADER_USER` / `_GROUPS` / `_EMAIL` | `X-Authentik-Username` / `X-Authentik-Groups` / `X-Authentik-Email` — identity injected by Caddy, trust these, implement no login |
|
||||||
|
| `TZ` | America/New_York |
|
||||||
|
|
||||||
|
SQLAlchemy URL: `mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}`
|
||||||
|
async: `mysql+aiomysql://...`
|
||||||
|
|
||||||
|
## Database from your own machine
|
||||||
|
MariaDB is reachable directly: `ovh1.dumpstervigilantes.rip:3306`, user `bot`, database `vanguardbot`,
|
||||||
|
password from the admin (`./configure.py --show` on the server). Root is local-only. New connections are
|
||||||
|
rate limited at the firewall; if yours get dropped, ask the admin to add your IP to `db_allow`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- Run as uid 1000 (files you write under `/media/clips` are owned like everything else there).
|
||||||
|
- If you write clips: `/media/clips/<discord-username>/<name>.mp4` plus `<name>.json` with
|
||||||
|
`{"timestamp": "<UTC ISO-8601>", "source": "medal", "source_url": "...", "uploader": "...", "title": "..."}`
|
||||||
|
— the Jellyfin plugin reads that sidecar for the exact recording time.
|
||||||
|
- Log to stdout; the container captures it.
|
||||||
8
bot/app/requirements.txt
Normal file
8
bot/app/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Baseline for a discord.py bot with MariaDB. Add what you need; then:
|
||||||
|
# docker compose build discord-bot && docker compose up -d discord-bot
|
||||||
|
discord.py>=2.4
|
||||||
|
aiomysql>=0.2
|
||||||
|
PyMySQL>=1.1
|
||||||
|
SQLAlchemy>=2.0
|
||||||
|
python-dotenv>=1.0
|
||||||
|
yt-dlp
|
||||||
7
bot/entrypoint.sh
Normal file
7
bot/entrypoint.sh
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run the bot if there is one; otherwise idle so the container stays up until code arrives.
|
||||||
|
if [ -f /app/main.py ]; then
|
||||||
|
exec python -u /app/main.py
|
||||||
|
fi
|
||||||
|
echo "discord-bot: no /app/main.py yet — drop the bot code into ./bot/app and 'docker compose restart discord-bot'"
|
||||||
|
exec sleep infinity
|
||||||
487
configure.py
Executable file
487
configure.py
Executable file
@@ -0,0 +1,487 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""vanguard-stream post-install configuration. Stateless: the config files ARE the state.
|
||||||
|
|
||||||
|
./configure.py --full first-time setup: asks every question (current values as defaults)
|
||||||
|
./configure.py manage: skips what is already set; add/remove/reset streamers & editors
|
||||||
|
./configure.py --show print current streamers, editors, domain and the OBS settings
|
||||||
|
./configure.py --hosts AUTH WATCH [GIT [BOT]] set the Authentik/Jellyfin/Gitea/bot hostnames and apply (non-interactive;
|
||||||
|
used by addCommunityMediaServer.sh)
|
||||||
|
|
||||||
|
Reads : Caddyfile (hostnames), mediamtx.yml (streamers), copyparty.conf (editors + admin), .env
|
||||||
|
Writes : Caddyfile, mediamtx.yml, copyparty.conf, dump-users.txt -> /etc/vanguard, .env (generated secrets +
|
||||||
|
Discord bot token/guild), folders, restarts
|
||||||
|
Set VG_DRY=1 to write files only (no sudo / docker / folders).
|
||||||
|
"""
|
||||||
|
import getpass, hashlib, os, re, secrets, shutil, subprocess, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
os.chdir(HERE)
|
||||||
|
DRY = os.environ.get("VG_DRY") == "1"
|
||||||
|
NAME_RE = re.compile(r"^[a-z0-9_-]{2,32}$")
|
||||||
|
BAD_PW = set(':&?#/ "\'\\\t') # would break the RTMP query string / SRT streamid / YAML
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ read current
|
||||||
|
def read_hosts():
|
||||||
|
"""{'main': host, 'auth': host, 'watch': host} from '# vanguard:<role>' markers; first block = main."""
|
||||||
|
hosts, role = {}, None
|
||||||
|
try:
|
||||||
|
for line in Path("Caddyfile").read_text().splitlines():
|
||||||
|
if line[:1].isspace(): continue # site blocks start at column 0
|
||||||
|
t = line.strip()
|
||||||
|
m = re.match(r"#\s*vanguard:(\w+)", t)
|
||||||
|
if m: role = m.group(1); continue
|
||||||
|
if t and not t.startswith(("#", "}", "{")) and t.endswith("{") and " " in t:
|
||||||
|
host = t.split()[0]
|
||||||
|
if "example.com" in host: role = None; continue
|
||||||
|
hosts[role or ("main" if "main" not in hosts else host)] = host
|
||||||
|
role = None
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
def read_domain(): return read_hosts().get("main", "")
|
||||||
|
|
||||||
|
def read_env():
|
||||||
|
env = {}
|
||||||
|
try:
|
||||||
|
for line in Path(".env").read_text().splitlines():
|
||||||
|
if "=" in line and not line.startswith("#"):
|
||||||
|
k, v = line.split("=", 1); env[k.strip()] = v.strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return env
|
||||||
|
|
||||||
|
def default_sub(main, sub):
|
||||||
|
labels = main.split(".")
|
||||||
|
base = ".".join(labels[1:]) if len(labels) >= 3 else main
|
||||||
|
return f"{sub}.{base}" if main else ""
|
||||||
|
|
||||||
|
def read_streamers():
|
||||||
|
out, cur = {}, None
|
||||||
|
try:
|
||||||
|
in_users = False
|
||||||
|
for line in Path("mediamtx.yml").read_text().splitlines():
|
||||||
|
if line.startswith("authInternalUsers:"):
|
||||||
|
in_users = True; continue
|
||||||
|
if in_users and line and not line.startswith(" "):
|
||||||
|
break
|
||||||
|
if in_users:
|
||||||
|
m = re.match(r"\s*-\s*user:\s*(\S+)", line)
|
||||||
|
if m: cur = m.group(1); continue
|
||||||
|
m = re.match(r"\s*pass:\s*(\S+)", line)
|
||||||
|
if m and cur:
|
||||||
|
if not m.group(1).startswith("CHANGEME"): out[cur] = m.group(1)
|
||||||
|
cur = None
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
def read_accounts():
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
in_acc = False
|
||||||
|
for line in Path("copyparty.conf").read_text().splitlines():
|
||||||
|
if line.strip() == "[accounts]": in_acc = True; continue
|
||||||
|
if in_acc and line.startswith("["): break
|
||||||
|
if in_acc:
|
||||||
|
m = re.match(r"\s*([A-Za-z0-9_-]+):\s*(\S+)", line)
|
||||||
|
if m and not m.group(2).startswith("CHANGEME"): out[m.group(1)] = m.group(2)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ prompts
|
||||||
|
def ask(q, default=""):
|
||||||
|
s = input(f"{q}{f' [{default}]' if default else ''}: ").strip()
|
||||||
|
return s or default
|
||||||
|
|
||||||
|
def yes(q, default=True):
|
||||||
|
d = "Y/n" if default else "y/N"
|
||||||
|
s = input(f"{q} [{d}]: ").strip().lower()
|
||||||
|
return default if not s else s.startswith("y")
|
||||||
|
|
||||||
|
def gen_pw(): return "".join(c for c in secrets.token_urlsafe(24) if c.isalnum())[:20]
|
||||||
|
|
||||||
|
def ask_pw(label, current=None):
|
||||||
|
"""keep / generate / type. Returns the password."""
|
||||||
|
opts = "(enter = generate" + (", k = keep current" if current else "") + ", or type one)"
|
||||||
|
while True:
|
||||||
|
prompt = f" password for {label} {opts}: "
|
||||||
|
s = (getpass.getpass(prompt) if sys.stdin.isatty() else input(prompt)).strip()
|
||||||
|
if not s: p = gen_pw(); print(f" generated: {p}"); return p
|
||||||
|
if s == "k" and current: return current
|
||||||
|
if BAD_PW & set(s): print(" avoid : & ? # / \\ quotes and spaces (they break stream keys)"); continue
|
||||||
|
if len(s) < 8: print(" at least 8 characters"); continue
|
||||||
|
return s
|
||||||
|
|
||||||
|
def ask_name(kind, taken):
|
||||||
|
while True:
|
||||||
|
n = ask(f" {kind} name (blank to stop)").lower()
|
||||||
|
if not n: return None
|
||||||
|
if not NAME_RE.match(n): print(" lowercase letters, digits, _ - (2-32 chars)"); continue
|
||||||
|
if n in taken: print(" already exists"); continue
|
||||||
|
return n
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ writers
|
||||||
|
AUTHENTIK_FORWARD_AUTH = """ # Authentik forward-auth (embedded outpost). Needs a Proxy Provider (forward auth, single application)
|
||||||
|
# for this external host in Authentik, assigned to the embedded outpost. Until then: 4xx, never open.
|
||||||
|
reverse_proxy /outpost.goauthentik.io/* 127.0.0.1:9000
|
||||||
|
forward_auth 127.0.0.1:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host=""):
|
||||||
|
extra = ""
|
||||||
|
if bot_host:
|
||||||
|
extra += f"""
|
||||||
|
# vanguard:bot
|
||||||
|
{bot_host} {{
|
||||||
|
encode zstd gzip
|
||||||
|
{AUTHENTIK_FORWARD_AUTH} reverse_proxy 127.0.0.1:8000
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
if git_host:
|
||||||
|
extra += f"""
|
||||||
|
# vanguard:git
|
||||||
|
{git_host} {{
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
if auth_host:
|
||||||
|
extra += f"""
|
||||||
|
# vanguard:auth
|
||||||
|
{auth_host} {{
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy 127.0.0.1:9000
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
if watch_host:
|
||||||
|
extra += f"""
|
||||||
|
# vanguard:watch
|
||||||
|
{watch_host} {{
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy 127.0.0.1:8096
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
Path("Caddyfile").write_text(f"""# vanguard:main
|
||||||
|
{domain} {{
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# dump endpoint (python service on the host, does its own auth)
|
||||||
|
handle /dump* {{
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Syncthing admin UI (its own login; strip the prefix, present a local Host to pass its host check)
|
||||||
|
redir /sync /sync/ 308
|
||||||
|
handle_path /sync/* {{
|
||||||
|
reverse_proxy 127.0.0.1:8384 {{
|
||||||
|
header_up Host {{upstream_hostport}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
# OBS browser dock (static)
|
||||||
|
handle_path /dock* {{
|
||||||
|
root * /srv/dock
|
||||||
|
file_server
|
||||||
|
}}
|
||||||
|
|
||||||
|
# everything else is copyparty (web UI + WebDAV)
|
||||||
|
handle {{
|
||||||
|
reverse_proxy 127.0.0.1:3923 {{
|
||||||
|
flush_interval -1
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
""" + extra)
|
||||||
|
|
||||||
|
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host=""):
|
||||||
|
env = read_env()
|
||||||
|
env["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com")
|
||||||
|
env["BOT_HOST"] = bot_host or env.get("BOT_HOST", "bot.example.com")
|
||||||
|
env.setdefault("GITEA_DB_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32])
|
||||||
|
env.setdefault("GITEA_RUNNER_TOKEN", "") # filled by ./registerGiteaRunner.sh
|
||||||
|
if discord_token is not None: env["DISCORD_BOT_TOKEN"] = discord_token
|
||||||
|
if discord_guild is not None: env["DISCORD_GUILD_ID"] = discord_guild
|
||||||
|
env.setdefault("AUTHENTIK_SECRET_KEY", secrets.token_urlsafe(48))
|
||||||
|
env.setdefault("AUTHENTIK_PG_PASS", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32])
|
||||||
|
env.setdefault("AUTHENTIK_TAG", "2025.8")
|
||||||
|
env.setdefault("MARIADB_ROOT_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32])
|
||||||
|
env.setdefault("BOT_DB_NAME", "vanguardbot")
|
||||||
|
env.setdefault("BOT_DB_USER", "bot")
|
||||||
|
env.setdefault("BOT_DB_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32])
|
||||||
|
env.setdefault("DISCORD_BOT_TOKEN", "") # fill in: Discord dev portal -> the Authentik app -> Bot
|
||||||
|
env.setdefault("DISCORD_GUILD_ID", "")
|
||||||
|
env["WATCH_HOST"] = watch_host or "watch.example.com"
|
||||||
|
Path(".env").write_text("".join(f"{k}={v}\n" for k, v in env.items()))
|
||||||
|
os.chmod(".env", 0o600)
|
||||||
|
|
||||||
|
def write_mediamtx(streamers):
|
||||||
|
users = "".join(f""" - user: {u}
|
||||||
|
pass: {p}
|
||||||
|
permissions:
|
||||||
|
- action: publish
|
||||||
|
path: ~^{u}/[A-Za-z0-9_.-]+$
|
||||||
|
""" for u, p in streamers.items()) or " []\n"
|
||||||
|
alt = "|".join(streamers) or "nobody"
|
||||||
|
Path("mediamtx.yml").write_text(f"""# MediaMTX — ingest only. No playback endpoints exposed. (generated by configure.py)
|
||||||
|
logLevel: info
|
||||||
|
|
||||||
|
rtsp: no
|
||||||
|
webrtc: no
|
||||||
|
hls: no
|
||||||
|
api: no
|
||||||
|
metrics: no
|
||||||
|
pprof: no
|
||||||
|
|
||||||
|
rtmp: yes
|
||||||
|
rtmpAddress: :1935
|
||||||
|
|
||||||
|
srt: yes
|
||||||
|
srtAddress: :8890
|
||||||
|
|
||||||
|
# ---- auth -------------------------------------------------------------
|
||||||
|
# Each streamer may publish only under their own prefix.
|
||||||
|
# RTMP key: <user>/Title?user=<user>&pass=<pass>
|
||||||
|
# SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass>
|
||||||
|
authMethod: internal
|
||||||
|
authInternalUsers:
|
||||||
|
{users}
|
||||||
|
# ---- recording --------------------------------------------------------
|
||||||
|
pathDefaults:
|
||||||
|
record: yes
|
||||||
|
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S
|
||||||
|
recordFormat: fmp4
|
||||||
|
recordPartDuration: 1s
|
||||||
|
recordSegmentDuration: 12h # one file per stream unless it runs >12h
|
||||||
|
recordDeleteAfter: 0s # never auto-delete; the archive job handles it
|
||||||
|
|
||||||
|
paths:
|
||||||
|
# any name under a streamer's prefix; inherits pathDefaults
|
||||||
|
~^({alt})/[A-Za-z0-9_.-]+$: {{}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def write_copyparty(admin_pw, editors):
|
||||||
|
acc = f" admin: {admin_pw}\n" + "".join(f" {u}: {p}\n" for u, p in editors.items())
|
||||||
|
eds = ", ".join(editors)
|
||||||
|
r_line = f" r: {eds}\n" if eds else ""
|
||||||
|
rw_line = f" rw: {eds}\n" if eds else ""
|
||||||
|
Path("copyparty.conf").write_text(f"""# copyparty — editors browse/preview/download; finals are writable. (generated by configure.py)
|
||||||
|
# Docs: https://github.com/9001/copyparty#server-config
|
||||||
|
|
||||||
|
[global]
|
||||||
|
e2dsa # index files for search
|
||||||
|
e2ts # read media tags
|
||||||
|
th-ff-jpg # video thumbnails via ffmpeg (cached under /cfg/hist)
|
||||||
|
hist: /cfg/hist
|
||||||
|
rproxy: 1 # trust first X-Forwarded-For hop (Caddy)
|
||||||
|
xff-src: 172.16.0.0/12, 10.0.0.0/8, 192.168.0.0/16, 127.0.0.0/8
|
||||||
|
no-robots
|
||||||
|
name: vanguard
|
||||||
|
usernames # login needs username + password (default is password-only)
|
||||||
|
# dark skin for the login page (served by Caddy from ./dock)
|
||||||
|
html-head: <link rel="stylesheet" href="/dock/vanguard.css">
|
||||||
|
|
||||||
|
[accounts]
|
||||||
|
{acc}
|
||||||
|
# streams: read for editors, full for admin
|
||||||
|
[/streams]
|
||||||
|
/w/streams
|
||||||
|
accs:
|
||||||
|
{r_line} rwmd: admin
|
||||||
|
|
||||||
|
# gamer clips (Syncthing drops them here)
|
||||||
|
[/clips]
|
||||||
|
/w/clips
|
||||||
|
accs:
|
||||||
|
{r_line} rwmd: admin
|
||||||
|
|
||||||
|
# editors upload finished work here
|
||||||
|
[/finals]
|
||||||
|
/w/finals
|
||||||
|
accs:
|
||||||
|
{rw_line} rwmd: admin
|
||||||
|
|
||||||
|
# dumped nights, purged after 3 days — admin only
|
||||||
|
[/trash]
|
||||||
|
/w/trash
|
||||||
|
accs:
|
||||||
|
rwmd: admin
|
||||||
|
""")
|
||||||
|
|
||||||
|
def write_dump_users(admin_pw, streamers):
|
||||||
|
h = lambda p: hashlib.sha256(p.encode()).hexdigest()
|
||||||
|
body = "# user:sha256hex(password) (generated by configure.py)\n"
|
||||||
|
body += "".join(f"{u}:{h(p)}\n" for u, p in streamers.items())
|
||||||
|
body += f"admin:{h(admin_pw)}\n"
|
||||||
|
Path("dump-users.txt").write_text(body)
|
||||||
|
|
||||||
|
def run(cmd, **kw):
|
||||||
|
print(" $", " ".join(cmd))
|
||||||
|
if DRY: return 0
|
||||||
|
return subprocess.call(cmd, **kw)
|
||||||
|
|
||||||
|
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host=""):
|
||||||
|
print("\n== writing configs")
|
||||||
|
write_caddy(domain, auth_host, watch_host, git_host, bot_host); write_mediamtx(streamers); write_copyparty(admin_pw, editors); write_dump_users(admin_pw, streamers)
|
||||||
|
write_env(watch_host, discord_token, discord_guild, git_host, bot_host)
|
||||||
|
print("== app data folders")
|
||||||
|
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache",
|
||||||
|
"/srv/appdata/authentik/media", "/srv/appdata/authentik/templates", "/srv/appdata/authentik/certs"])
|
||||||
|
run(["sudo", "install", "-d", "/srv/appdata/authentik/db", "/srv/appdata/authentik/redis", "/srv/appdata/mariadb"])
|
||||||
|
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/discord-bot", "/srv/appdata/gitea", "/srv/appdata/act_runner"])
|
||||||
|
print("== folders for streamers")
|
||||||
|
for u in streamers:
|
||||||
|
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", f"/srv/media/streams/{u}", f"/srv/media/clips/{u}"])
|
||||||
|
if not DRY and not Path(f"/srv/media/clips/{u}/.stignore").exists():
|
||||||
|
run(["sudo", "install", "-m", "0644", "-o", "1000", "-g", "1000", "syncthing/stignore", f"/srv/media/clips/{u}/.stignore"])
|
||||||
|
print("== dump users + restart dumpd")
|
||||||
|
run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"])
|
||||||
|
run(["sudo", "install", "-m", "0640", "dump-users.txt", "/etc/vanguard/dump-users.txt"])
|
||||||
|
run(["sudo", "systemctl", "restart", "dumpd"])
|
||||||
|
print("== containers")
|
||||||
|
dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"]
|
||||||
|
run(dc + ["up", "-d", "--build"])
|
||||||
|
run(dc + ["restart", "mediamtx", "copyparty", "caddy"])
|
||||||
|
|
||||||
|
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host=""):
|
||||||
|
print(f"\n== domain : {domain or '(not set)'}")
|
||||||
|
print(f"== bot web : https://{bot_host}/ (behind Authentik forward-auth; create the Proxy Provider for it)" if bot_host else "== bot web : (not set — run --full)")
|
||||||
|
print(f"== gitea : https://{git_host}/ ssh port 2222 (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)")
|
||||||
|
print(f"== authentik: https://{auth_host}/ (first run: https://{auth_host}/if/flow/initial-setup/)" if auth_host else "== authentik: (not set — run --full)")
|
||||||
|
print(f"== jellyfin : https://{watch_host}/" if watch_host else "== jellyfin : (not set — run --full)")
|
||||||
|
env = read_env()
|
||||||
|
tok = env.get("DISCORD_BOT_TOKEN", "")
|
||||||
|
print(f"== discord : guild {env.get('DISCORD_GUILD_ID') or '(not set)'}; bot token {'set (' + tok[:6] + '…)' if tok else '(not set)'}")
|
||||||
|
print(f"== mariadb : {domain or 'HOST'}:3306 (public, root local-only) root pass {env.get('MARIADB_ROOT_PASSWORD','(run apply)')}")
|
||||||
|
print(f" bot : db {env.get('BOT_DB_NAME','vanguardbot')} user {env.get('BOT_DB_USER','bot')} pass {env.get('BOT_DB_PASSWORD','(run apply)')}")
|
||||||
|
print(f" gitea : db gitea user gitea pass {env.get('GITEA_DB_PASSWORD','(run apply)')} (created on MariaDB first init, else ./createGiteaDb.sh)")
|
||||||
|
print(f"== admin : admin / {admin_pw or '(not set)'} (copyparty + dump)")
|
||||||
|
print("== editors :", ", ".join(f"{u} / {p}" for u, p in editors.items()) or "(none)")
|
||||||
|
print("== streamers:")
|
||||||
|
for u, p in streamers.items():
|
||||||
|
print(f"\n {u} password {p}")
|
||||||
|
print(f" OBS server : rtmp://{domain or 'HOST'}:1935/")
|
||||||
|
print(f" OBS key : {u}/TITLE?user={u}&pass={p} (the dock sets this for them)")
|
||||||
|
print(f" SRT : srt://{domain or 'HOST'}:8890?streamid=publish:{u}/TITLE:{u}:{p}")
|
||||||
|
print(f" Dock URL : https://{domain or 'HOST'}/dock/ (settings: host={domain or 'HOST'}, user={u}, pass={p})")
|
||||||
|
if not streamers: print(" (none)")
|
||||||
|
print(f"\n editors log in at https://{domain or 'HOST'}/ — WebDAV at the same URL")
|
||||||
|
print(f" verify: curl -sI https://{domain or 'HOST'}/dock/ | head -1 (expect 200)")
|
||||||
|
print(f" curl -s -o /dev/null -w '%{{http_code}}\\n' https://{domain or 'HOST'}/dump (expect 401)\n")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ modes
|
||||||
|
def manage_users(kind, users):
|
||||||
|
"""add / remove / reset loop for one kind of user; mutates dict."""
|
||||||
|
while True:
|
||||||
|
print(f"\n {kind}s: {', '.join(users) or '(none)'}")
|
||||||
|
c = ask(" [a]dd [r]emove [p]assword reset [d]one", "d").lower()[:1]
|
||||||
|
if c == "d": return
|
||||||
|
if c == "a":
|
||||||
|
n = ask_name(kind, users)
|
||||||
|
if n: users[n] = ask_pw(n)
|
||||||
|
elif c in "rp":
|
||||||
|
n = ask(f" which {kind}").lower()
|
||||||
|
if n not in users: print(" no such user"); continue
|
||||||
|
if c == "r":
|
||||||
|
if yes(f" remove {n}? (their files stay on disk)", False): del users[n]
|
||||||
|
else:
|
||||||
|
users[n] = ask_pw(n, users[n])
|
||||||
|
|
||||||
|
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host=""):
|
||||||
|
print("== FULL SETUP (enter keeps the shown default)\n")
|
||||||
|
while True:
|
||||||
|
domain = ask("public hostname (DNS A record must point here)", domain)
|
||||||
|
if domain and "." in domain and " " not in domain: break
|
||||||
|
print(" need a hostname like media.example.com")
|
||||||
|
auth_host = ask("Authentik hostname (CNAME to the above)", auth_host or default_sub(domain, "auth"))
|
||||||
|
watch_host = ask("Jellyfin hostname (CNAME to the above)", watch_host or default_sub(domain, "watch"))
|
||||||
|
git_host = ask("Gitea hostname (CNAME to the above)", git_host or default_sub(domain, "git"))
|
||||||
|
bot_host = ask("Bot web UI hostname (CNAME to the above)", bot_host or default_sub(domain, "bot"))
|
||||||
|
print("\n-- Discord bot (optional; the Bot added to the Authentik Discord application). Enter keeps the current value.")
|
||||||
|
discord_token, discord_guild = ask_discord()
|
||||||
|
print("\n-- admin (copyparty admin + may dump anyone's night)")
|
||||||
|
admin_pw = ask_pw("admin", admin_pw or None)
|
||||||
|
print("\n-- streamers (OBS publishers; each gets streams/<name> and clips/<name>)")
|
||||||
|
for u in list(streamers):
|
||||||
|
k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1]
|
||||||
|
if k == "r": del streamers[u]
|
||||||
|
elif k == "p": streamers[u] = ask_pw(u, streamers[u])
|
||||||
|
print(" add streamers:")
|
||||||
|
while True:
|
||||||
|
n = ask_name("streamer", streamers)
|
||||||
|
if not n: break
|
||||||
|
streamers[n] = ask_pw(n)
|
||||||
|
print("\n-- editors (copyparty logins: read streams/clips, write finals)")
|
||||||
|
for u in list(editors):
|
||||||
|
k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1]
|
||||||
|
if k == "r": del editors[u]
|
||||||
|
elif k == "p": editors[u] = ask_pw(u, editors[u])
|
||||||
|
print(" add editors:")
|
||||||
|
while True:
|
||||||
|
n = ask_name("editor", editors)
|
||||||
|
if not n: break
|
||||||
|
editors[n] = ask_pw(n)
|
||||||
|
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host
|
||||||
|
|
||||||
|
def ask_discord():
|
||||||
|
env = read_env()
|
||||||
|
cur_tok, cur_guild = env.get("DISCORD_BOT_TOKEN", ""), env.get("DISCORD_GUILD_ID", "")
|
||||||
|
prompt = f" bot token{' [keep ' + cur_tok[:6] + '…]' if cur_tok else ' [none]'}: "
|
||||||
|
tok = (getpass.getpass(prompt) if sys.stdin.isatty() else input(prompt)).strip() or cur_tok
|
||||||
|
guild = ask(" server (guild) ID", cur_guild)
|
||||||
|
return tok, guild
|
||||||
|
|
||||||
|
def normal(domain, admin_pw, streamers, editors):
|
||||||
|
missing = [x for x, v in (("domain", domain), ("admin password", admin_pw)) if not v]
|
||||||
|
if missing:
|
||||||
|
print(f"not set yet: {', '.join(missing)} — run ./configure.py --full"); sys.exit(1)
|
||||||
|
print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)")
|
||||||
|
discord = (None, None)
|
||||||
|
while True:
|
||||||
|
c = ask("\n[s]treamers [e]ditors [d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1]
|
||||||
|
if c == "s": manage_users("streamer", streamers)
|
||||||
|
elif c == "e": manage_users("editor", editors)
|
||||||
|
elif c == "d": discord = ask_discord()
|
||||||
|
elif c == "v": show(domain, admin_pw, streamers, editors)
|
||||||
|
elif c == "a": return discord
|
||||||
|
elif c == "q": return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
hosts = read_hosts()
|
||||||
|
domain, auth_host, watch_host, git_host, bot_host = hosts.get("main", ""), hosts.get("auth", ""), hosts.get("watch", ""), hosts.get("git", ""), hosts.get("bot", "")
|
||||||
|
streamers, accounts = read_streamers(), read_accounts()
|
||||||
|
admin_pw = accounts.pop("admin", "")
|
||||||
|
editors = accounts
|
||||||
|
if mode == "--show":
|
||||||
|
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host); return
|
||||||
|
if mode == "--hosts":
|
||||||
|
if len(sys.argv) not in (4, 5, 6): print("usage: configure.py --hosts AUTH_HOST WATCH_HOST [GIT_HOST [BOT_HOST]]"); sys.exit(2)
|
||||||
|
if not domain or not admin_pw: print("domain/admin not set yet — run ./configure.py --full first"); sys.exit(1)
|
||||||
|
auth_host, watch_host = sys.argv[2].strip(), sys.argv[3].strip()
|
||||||
|
if len(sys.argv) >= 5: git_host = sys.argv[4].strip()
|
||||||
|
if len(sys.argv) == 6: bot_host = sys.argv[5].strip()
|
||||||
|
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host)
|
||||||
|
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host); return
|
||||||
|
discord_token = discord_guild = None
|
||||||
|
if mode == "--full":
|
||||||
|
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host)
|
||||||
|
if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return
|
||||||
|
elif mode == "":
|
||||||
|
r = normal(domain, admin_pw, streamers, editors)
|
||||||
|
if r is False: print("nothing written"); return
|
||||||
|
discord_token, discord_guild = r
|
||||||
|
else:
|
||||||
|
print(__doc__); sys.exit(2)
|
||||||
|
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host)
|
||||||
|
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try: main()
|
||||||
|
except (KeyboardInterrupt, EOFError): print("\naborted, nothing written")
|
||||||
47
copyparty.conf
Normal file
47
copyparty.conf
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# copyparty — editors browse/preview/download; finals are writable.
|
||||||
|
# Docs: https://github.com/9001/copyparty#server-config
|
||||||
|
|
||||||
|
[global]
|
||||||
|
e2dsa # index files for search
|
||||||
|
e2ts # read media tags
|
||||||
|
th-ff-jpg # video thumbnails via ffmpeg (cached under /cfg/hist)
|
||||||
|
hist: /cfg/hist
|
||||||
|
rproxy: 1 # trust first X-Forwarded-For hop (Caddy)
|
||||||
|
xff-src: 172.16.0.0/12, 10.0.0.0/8, 192.168.0.0/16
|
||||||
|
no-robots
|
||||||
|
name: vanguard
|
||||||
|
usernames # login needs username + password (default is password-only)
|
||||||
|
# dark skin for the login page (served by Caddy from ./dock)
|
||||||
|
html-head: <link rel="stylesheet" href="/dock/vanguard.css">
|
||||||
|
|
||||||
|
[accounts]
|
||||||
|
admin: CHANGEME_ADMIN
|
||||||
|
editor1: CHANGEME_E1
|
||||||
|
editor2: CHANGEME_E2
|
||||||
|
|
||||||
|
# streams: read for editors, full for admin
|
||||||
|
[/streams]
|
||||||
|
/w/streams
|
||||||
|
accs:
|
||||||
|
r: editor1, editor2
|
||||||
|
rwmd: admin
|
||||||
|
|
||||||
|
# gamer clips (Syncthing drops them here)
|
||||||
|
[/clips]
|
||||||
|
/w/clips
|
||||||
|
accs:
|
||||||
|
r: editor1, editor2
|
||||||
|
rwmd: admin
|
||||||
|
|
||||||
|
# editors upload finished work here
|
||||||
|
[/finals]
|
||||||
|
/w/finals
|
||||||
|
accs:
|
||||||
|
rw: editor1, editor2
|
||||||
|
rwmd: admin
|
||||||
|
|
||||||
|
# dumped nights, purged after 3 days — admin only
|
||||||
|
[/trash]
|
||||||
|
/w/trash
|
||||||
|
accs:
|
||||||
|
rwmd: admin
|
||||||
15
createGiteaDb.sh
Executable file
15
createGiteaDb.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fallback for a MariaDB that was initialised before Gitea was added: create the gitea db/user now.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
ROOT=$(grep -E '^MARIADB_ROOT_PASSWORD=' .env | cut -d= -f2-)
|
||||||
|
PW=$(grep -E '^GITEA_DB_PASSWORD=' .env | cut -d= -f2-)
|
||||||
|
[[ -n $ROOT && -n $PW ]] || { echo "MARIADB_ROOT_PASSWORD / GITEA_DB_PASSWORD missing in .env — run ./configure.py and apply first"; exit 1; }
|
||||||
|
docker exec -i mariadb mariadb -uroot -p"$ROOT" <<SQL
|
||||||
|
CREATE DATABASE IF NOT EXISTS gitea CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER IF NOT EXISTS 'gitea'@'%' IDENTIFIED BY '${PW}';
|
||||||
|
ALTER USER 'gitea'@'%' IDENTIFIED BY '${PW}';
|
||||||
|
GRANT ALL PRIVILEGES ON gitea.* TO 'gitea'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
SQL
|
||||||
|
echo "gitea db/user ready; now: docker compose up -d gitea"
|
||||||
151
dock/index.html
Normal file
151
dock/index.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Vanguard Stream</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --box:#2a2a2a; }
|
||||||
|
html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.4 system-ui,sans-serif}
|
||||||
|
main{padding:12px;max-width:420px}
|
||||||
|
h1{font-size:15px;margin:0 0 10px;color:var(--mut);font-weight:600}
|
||||||
|
label{display:block;margin:8px 0 2px;color:var(--mut);font-size:12px}
|
||||||
|
input{width:100%;box-sizing:border-box;padding:8px;border:1px solid #444;border-radius:6px;background:var(--box);color:var(--fg);font-size:14px}
|
||||||
|
button{width:100%;margin-top:10px;padding:11px;border:0;border-radius:6px;font-size:15px;font-weight:600;cursor:pointer;color:#fff;background:var(--acc)}
|
||||||
|
button.live{background:var(--ok);color:#000}
|
||||||
|
button.dump{background:var(--bad)}
|
||||||
|
button:disabled{opacity:.5;cursor:default}
|
||||||
|
#status{margin-top:10px;padding:8px;border-radius:6px;background:var(--box);color:var(--mut);min-height:1.4em;white-space:pre-wrap}
|
||||||
|
details{margin-top:14px}
|
||||||
|
summary{color:var(--mut);cursor:pointer;font-size:12px}
|
||||||
|
.row{display:flex;gap:8px}
|
||||||
|
.row button{margin-top:10px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Vanguard Stream</h1>
|
||||||
|
|
||||||
|
<label for="title">Stream title</label>
|
||||||
|
<input id="title" placeholder="VanguardTraining10-10-2025" autocomplete="off">
|
||||||
|
<button id="golive" class="live">Go Live</button>
|
||||||
|
<div class="row">
|
||||||
|
<button id="stop">Stop Stream</button>
|
||||||
|
<button id="dump" class="dump">Dump tonight</button>
|
||||||
|
</div>
|
||||||
|
<div id="status">idle</div>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Settings (saved in this dock)</summary>
|
||||||
|
<label>Ingest host (RTMP server)</label><input id="host" placeholder="media.example.com">
|
||||||
|
<label>Your streamer name</label><input id="user" placeholder="techy">
|
||||||
|
<label>Your stream password</label><input id="pass" type="password">
|
||||||
|
<label>OBS WebSocket password</label><input id="obspw" type="password">
|
||||||
|
<label>OBS WebSocket port</label><input id="obsport" value="4455">
|
||||||
|
<button id="save">Save settings</button>
|
||||||
|
</details>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ---- settings --------------------------------------------------------------
|
||||||
|
const KEYS = ["host","user","pass","obspw","obsport"];
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const status = (msg, cls) => { $("status").textContent = msg; $("status").style.color = cls==="bad"?"#e74c3c":cls==="ok"?"#2ecc71":"#9a9a9a"; };
|
||||||
|
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} }
|
||||||
|
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } }
|
||||||
|
load();
|
||||||
|
$("save").onclick = save;
|
||||||
|
|
||||||
|
const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()]));
|
||||||
|
const cleanTitle = t => t.trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_.-]/g, "");
|
||||||
|
|
||||||
|
// ---- obs-websocket v5 ------------------------------------------------------
|
||||||
|
async function sha256b64(s){
|
||||||
|
const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(h)));
|
||||||
|
}
|
||||||
|
function obs(cfg){
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${cfg.obsport||4455}`);
|
||||||
|
const pending = new Map(); let n = 0;
|
||||||
|
ws.onerror = () => reject(new Error("cannot reach OBS WebSocket (enable it in Tools > WebSocket Server Settings)"));
|
||||||
|
ws.onmessage = async ev => {
|
||||||
|
const m = JSON.parse(ev.data);
|
||||||
|
if (m.op === 0) { // Hello
|
||||||
|
let auth;
|
||||||
|
if (m.d.authentication) {
|
||||||
|
const secret = await sha256b64(cfg.obspw + m.d.authentication.salt);
|
||||||
|
auth = await sha256b64(secret + m.d.authentication.challenge);
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify({op:1, d:{rpcVersion:1, authentication:auth, eventSubscriptions:0}}));
|
||||||
|
} else if (m.op === 2) { // Identified
|
||||||
|
resolve({
|
||||||
|
call(type, data){ return new Promise((res, rej) => {
|
||||||
|
const id = String(++n); pending.set(id, {res, rej});
|
||||||
|
ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}}));
|
||||||
|
}); },
|
||||||
|
close(){ ws.close(); }
|
||||||
|
});
|
||||||
|
} else if (m.op === 7) { // RequestResponse
|
||||||
|
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId);
|
||||||
|
if (!p) return;
|
||||||
|
m.d.requestStatus.result ? p.res(m.d.responseData) : p.rej(new Error(m.d.requestStatus.comment || m.d.requestType + " failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = ev => { if (ev.code === 4009) reject(new Error("OBS WebSocket password wrong")); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- actions ---------------------------------------------------------------
|
||||||
|
$("golive").onclick = async () => {
|
||||||
|
const c = cfg(), title = cleanTitle($("title").value);
|
||||||
|
if (!title) return status("type a title first", "bad");
|
||||||
|
if (!c.host || !c.user || !c.pass) return status("fill in settings first", "bad");
|
||||||
|
$("golive").disabled = true;
|
||||||
|
try {
|
||||||
|
status("connecting to OBS…");
|
||||||
|
const o = await obs(c);
|
||||||
|
const key = `${c.user}/${title}?user=${encodeURIComponent(c.user)}&pass=${encodeURIComponent(c.pass)}`;
|
||||||
|
await o.call("SetStreamServiceSettings", {
|
||||||
|
streamServiceType: "rtmp_custom",
|
||||||
|
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key, use_auth: false }
|
||||||
|
});
|
||||||
|
const st = await o.call("GetStreamStatus");
|
||||||
|
if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); }
|
||||||
|
await o.call("StartStream");
|
||||||
|
o.close();
|
||||||
|
status(`LIVE → ${c.user}/${title}`, "ok");
|
||||||
|
} catch (e) { status(e.message, "bad"); }
|
||||||
|
$("golive").disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
$("stop").onclick = async () => {
|
||||||
|
try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); }
|
||||||
|
catch (e) { status(e.message, "bad"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
let armed = 0;
|
||||||
|
$("dump").onclick = async () => {
|
||||||
|
const c = cfg();
|
||||||
|
if (!c.user || !c.pass) return status("fill in settings first", "bad");
|
||||||
|
const auth = "Basic " + btoa(`${c.user}:${c.pass}`);
|
||||||
|
try {
|
||||||
|
if (Date.now() - armed > 10000) { // first click: show what would go, arm for 10 s
|
||||||
|
status("checking what tonight contains…");
|
||||||
|
const dry = await (await fetch("/dump", {headers:{Authorization:auth}})).json();
|
||||||
|
if (dry.error) throw new Error(dry.error);
|
||||||
|
if (!dry.files.length) return status(`nothing recorded for ${dry.night}`);
|
||||||
|
armed = Date.now();
|
||||||
|
$("dump").textContent = "Click again to confirm";
|
||||||
|
setTimeout(() => { $("dump").textContent = "Dump tonight"; armed = 0; }, 10000);
|
||||||
|
return status(`${dry.files.length} file(s), ${dry.gb} GB from the night of ${dry.night}.\nClick Dump again within 10 s to confirm. Undo window: 3 days (ask admin).`);
|
||||||
|
}
|
||||||
|
armed = 0; $("dump").textContent = "Dump tonight";
|
||||||
|
const r = await (await fetch("/dump", {method:"POST", headers:{Authorization:auth, "Content-Type":"application/json"}, body:"{}"})).json();
|
||||||
|
if (r.error) throw new Error(r.error);
|
||||||
|
status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok");
|
||||||
|
} catch (e) { status("dump failed: " + e.message, "bad"); }
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
dock/vanguard.css
Normal file
55
dock/vanguard.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/* Vanguard skin for copyparty's control-panel / login page only.
|
||||||
|
Scoped to #wrap (splash) so the file browser keeps copyparty's own theme. */
|
||||||
|
|
||||||
|
html:has(#wrap > #lf), html:has(#wrap > div > #lf) { background:#1e1e1e; }
|
||||||
|
body:has(#wrap > div > #lf), body:has(#wrap > #b) {
|
||||||
|
margin:0; background:#1e1e1e; color:#e6e6e6;
|
||||||
|
font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||||
|
min-height:100vh; display:flex; align-items:center; justify-content:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#wrap {
|
||||||
|
width:min(420px, calc(100vw - 32px)); margin:24px auto; padding:28px 28px 22px;
|
||||||
|
background:#2a2a2a; border:1px solid #3a3a3a; border-radius:12px; box-shadow:0 12px 40px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
#wrap::before {
|
||||||
|
content:"Vanguard Stream"; display:block; font-size:22px; font-weight:700; letter-spacing:.3px;
|
||||||
|
color:#fff; margin:0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greeting */
|
||||||
|
#wrap > #b { margin:0 0 20px; color:#9a9a9a; font-size:13px; }
|
||||||
|
#wrap > #b small { color:#7a7a7a; }
|
||||||
|
|
||||||
|
/* clutter: refresh/connect links, "other stuff", recent uploads, reset, footer, repl */
|
||||||
|
#wrap > #a, #wrap > #v, #wrap > #cc, #wrap .agr, #repl, #pb { display:none !important; }
|
||||||
|
|
||||||
|
/* headings */
|
||||||
|
#wrap h1 { font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:#9a9a9a; margin:18px 0 8px; }
|
||||||
|
#wrap > #l { margin-top:0; }
|
||||||
|
|
||||||
|
/* login form */
|
||||||
|
#lf { display:grid; gap:10px; }
|
||||||
|
#lf input[type=text], #lf input[type=password] {
|
||||||
|
width:100%; box-sizing:border-box; padding:11px 12px; font-size:15px;
|
||||||
|
background:#1e1e1e; color:#e6e6e6; border:1px solid #444; border-radius:8px; outline:none;
|
||||||
|
}
|
||||||
|
#lf input:focus { border-color:#3d8bfd; box-shadow:0 0 0 3px rgba(61,139,253,.25); }
|
||||||
|
#lf input[type=submit] {
|
||||||
|
width:100%; padding:12px; margin-top:4px; font-size:15px; font-weight:600; cursor:pointer;
|
||||||
|
background:#3d8bfd; color:#fff; border:0; border-radius:8px;
|
||||||
|
}
|
||||||
|
#lf input[type=submit]:hover { background:#2f7af0; }
|
||||||
|
#lm { color:#e74c3c; font-size:13px; min-height:1em; }
|
||||||
|
|
||||||
|
/* logged in: volume list becomes tiles */
|
||||||
|
#wrap ul { list-style:none; padding:0; margin:6px 0 0; display:grid; gap:8px; }
|
||||||
|
#wrap ul li a {
|
||||||
|
display:block; padding:12px 14px; border-radius:8px; background:#1e1e1e; border:1px solid #3a3a3a;
|
||||||
|
color:#e6e6e6; text-decoration:none; font-weight:600;
|
||||||
|
}
|
||||||
|
#wrap ul li a:hover { border-color:#3d8bfd; background:#232833; }
|
||||||
|
#wrap ul li a::before { content:"▸ "; color:#3d8bfd; }
|
||||||
|
|
||||||
|
/* logout / misc links that remain */
|
||||||
|
#wrap a { color:#3d8bfd; }
|
||||||
248
docker-compose.yml
Normal file
248
docker-compose.yml
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
services:
|
||||||
|
mediamtx:
|
||||||
|
image: bluenviron/mediamtx:latest
|
||||||
|
container_name: mediamtx
|
||||||
|
user: "1000:1000" # recordings owned by the debian user, so the archive pull can delete them
|
||||||
|
ports:
|
||||||
|
- "1935:1935" # RTMP ingest
|
||||||
|
- "8890:8890/udp" # SRT ingest
|
||||||
|
volumes:
|
||||||
|
- ./mediamtx.yml:/mediamtx.yml:ro
|
||||||
|
- /srv/media/streams:/recordings
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
copyparty:
|
||||||
|
image: copyparty/ac:latest
|
||||||
|
container_name: copyparty
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3923:3923"
|
||||||
|
volumes:
|
||||||
|
- /srv/media:/w
|
||||||
|
- ./copyparty.conf:/cfg/copyparty.conf:ro
|
||||||
|
- copyparty_hist:/cfg/hist
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
syncthing:
|
||||||
|
image: syncthing/syncthing:latest
|
||||||
|
container_name: syncthing
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
ports:
|
||||||
|
- "22000:22000/tcp"
|
||||||
|
- "22000:22000/udp"
|
||||||
|
- "127.0.0.1:8384:8384" # admin UI, reach via ssh tunnel
|
||||||
|
volumes:
|
||||||
|
- /srv/media/.syncthing:/var/syncthing/config # created by install.sh, owned by uid 1000
|
||||||
|
- /srv/media/clips:/var/syncthing/clips
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:latest
|
||||||
|
container_name: caddy
|
||||||
|
depends_on: [copyparty]
|
||||||
|
network_mode: host # talks to copyparty (127.0.0.1:3923) and dumpd (127.0.0.1:8080) on loopback
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./dock:/srv/dock:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- identity (Authentik)
|
||||||
|
authentik-db:
|
||||||
|
image: docker.io/library/postgres:16-alpine
|
||||||
|
container_name: authentik-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: authentik
|
||||||
|
POSTGRES_USER: authentik
|
||||||
|
POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||||
|
volumes:
|
||||||
|
- /srv/appdata/authentik/db:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
authentik-redis:
|
||||||
|
image: docker.io/library/redis:alpine
|
||||||
|
container_name: authentik-redis
|
||||||
|
command: --save 60 1 --loglevel warning
|
||||||
|
volumes:
|
||||||
|
- /srv/appdata/authentik/redis:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
authentik-server:
|
||||||
|
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.8}
|
||||||
|
container_name: authentik-server
|
||||||
|
command: server
|
||||||
|
environment: &authentik_env
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9000:9000"
|
||||||
|
volumes:
|
||||||
|
- /srv/appdata/authentik/media:/media
|
||||||
|
- /srv/appdata/authentik/templates:/templates
|
||||||
|
depends_on:
|
||||||
|
authentik-db: { condition: service_healthy }
|
||||||
|
authentik-redis: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
authentik-worker:
|
||||||
|
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.8}
|
||||||
|
container_name: authentik-worker
|
||||||
|
command: worker
|
||||||
|
environment: *authentik_env
|
||||||
|
volumes:
|
||||||
|
- /srv/appdata/authentik/media:/media
|
||||||
|
- /srv/appdata/authentik/templates:/templates
|
||||||
|
- /srv/appdata/authentik/certs:/certs
|
||||||
|
depends_on:
|
||||||
|
authentik-db: { condition: service_healthy }
|
||||||
|
authentik-redis: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- watching (Jellyfin, direct play only)
|
||||||
|
jellyfin:
|
||||||
|
image: jellyfin/jellyfin:latest
|
||||||
|
container_name: jellyfin
|
||||||
|
user: "1000:1000"
|
||||||
|
environment:
|
||||||
|
JELLYFIN_PublishedServerUrl: https://${WATCH_HOST:-watch.example.com}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8096:8096"
|
||||||
|
volumes:
|
||||||
|
- /srv/appdata/jellyfin/config:/config
|
||||||
|
- /srv/appdata/jellyfin/cache:/cache
|
||||||
|
- /srv/media/streams:/media/streams:ro
|
||||||
|
- /srv/media/clips:/media/clips:ro
|
||||||
|
- /srv/media/finals:/media/finals:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- database (for the Discord bot and friends)
|
||||||
|
mariadb:
|
||||||
|
image: mariadb:11
|
||||||
|
container_name: mariadb
|
||||||
|
network_mode: host # PUBLIC on 3306: host networking so the nftables input rules (db_allow / rate
|
||||||
|
# limit) actually apply — Docker-published ports bypass the host firewall.
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
|
||||||
|
MARIADB_ROOT_HOST: localhost # root only from the box itself; the bot user may connect from anywhere
|
||||||
|
MARIADB_DATABASE: ${BOT_DB_NAME:-vanguardbot}
|
||||||
|
MARIADB_USER: ${BOT_DB_USER:-bot}
|
||||||
|
MARIADB_PASSWORD: ${BOT_DB_PASSWORD}
|
||||||
|
GITEA_DB_PASSWORD: ${GITEA_DB_PASSWORD} # used by mariadb/init/10-gitea.sh on first init
|
||||||
|
volumes:
|
||||||
|
- /srv/appdata/mariadb:/var/lib/mysql
|
||||||
|
- ./mariadb/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Discord bot (python; code lives in ./bot/app)
|
||||||
|
discord-bot:
|
||||||
|
build: ./bot
|
||||||
|
container_name: discord-bot
|
||||||
|
user: "1000:1000"
|
||||||
|
environment:
|
||||||
|
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:-}
|
||||||
|
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-}
|
||||||
|
DB_HOST: host.docker.internal # mariadb runs on the host network
|
||||||
|
DB_PORT: "3306"
|
||||||
|
DB_NAME: ${BOT_DB_NAME:-vanguardbot}
|
||||||
|
DB_USER: ${BOT_DB_USER:-bot}
|
||||||
|
DB_PASSWORD: ${BOT_DB_PASSWORD}
|
||||||
|
MEDIA_CLIPS: /media/clips
|
||||||
|
WEB_HOST: 0.0.0.0
|
||||||
|
WEB_PORT: "8000"
|
||||||
|
WEB_PUBLIC_URL: https://${BOT_HOST:-bot.example.com}
|
||||||
|
AUTH_HEADER_USER: X-Authentik-Username
|
||||||
|
AUTH_HEADER_GROUPS: X-Authentik-Groups
|
||||||
|
AUTH_HEADER_EMAIL: X-Authentik-Email
|
||||||
|
TZ: America/New_York
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000" # web UI; public only via Caddy + Authentik forward-auth at https://BOT_HOST/
|
||||||
|
volumes:
|
||||||
|
- ./bot/app:/app # the bot's source; edit and `docker compose restart discord-bot`
|
||||||
|
- /srv/media/clips:/media/clips # rw: for a clip-submissions feature (writes into clips/<user>/)
|
||||||
|
- /srv/appdata/discord-bot:/data # persistent scratch (sqlite, caches) if the bot wants files
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
mariadb: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Gitea + Actions runner
|
||||||
|
gitea:
|
||||||
|
image: docker.gitea.com/gitea:1.24
|
||||||
|
container_name: gitea
|
||||||
|
environment:
|
||||||
|
USER_UID: "1000"
|
||||||
|
USER_GID: "1000"
|
||||||
|
GITEA__database__DB_TYPE: mysql
|
||||||
|
GITEA__database__HOST: host.docker.internal:3306 # mariadb runs on the host network
|
||||||
|
GITEA__database__NAME: gitea
|
||||||
|
GITEA__database__USER: gitea
|
||||||
|
GITEA__database__PASSWD: ${GITEA_DB_PASSWORD}
|
||||||
|
GITEA__server__DOMAIN: ${GIT_HOST:-git.example.com}
|
||||||
|
GITEA__server__ROOT_URL: https://${GIT_HOST:-git.example.com}/
|
||||||
|
GITEA__server__HTTP_PORT: "3000"
|
||||||
|
GITEA__server__SSH_DOMAIN: ${GIT_HOST:-git.example.com}
|
||||||
|
GITEA__server__SSH_PORT: "2222" # what users put in their remotes
|
||||||
|
GITEA__server__SSH_LISTEN_PORT: "22" # inside the container
|
||||||
|
GITEA__service__DISABLE_REGISTRATION: "true" # accounts are created by an admin (or via Authentik OIDC later)
|
||||||
|
GITEA__actions__ENABLED: "true"
|
||||||
|
GITEA__actions__DEFAULT_ACTIONS_URL: github
|
||||||
|
GITEA__webhook__ALLOWED_HOST_LIST: private,loopback,external
|
||||||
|
GITEA__time__DEFAULT_UI_LOCATION: America/New_York
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
- "2222:22"
|
||||||
|
volumes:
|
||||||
|
- /srv/appdata/gitea:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
mariadb: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
act_runner:
|
||||||
|
image: docker.gitea.com/act_runner:latest
|
||||||
|
container_name: act_runner
|
||||||
|
environment:
|
||||||
|
GITEA_INSTANCE_URL: https://${GIT_HOST:-git.example.com}/
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_TOKEN:-} # set by ./registerGiteaRunner.sh
|
||||||
|
GITEA_RUNNER_NAME: vanguard-runner
|
||||||
|
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,dotnet:docker://mcr.microsoft.com/dotnet/sdk:10.0,python:docker://python:3.12"
|
||||||
|
volumes:
|
||||||
|
- /srv/appdata/act_runner:/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # jobs run as sibling containers; root-equivalent on the host
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
copyparty_hist:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
6
dump-users.txt
Normal file
6
dump-users.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# user:sha256hex(password) — same user names as mediamtx.yml
|
||||||
|
# generate: printf '%s' 'password' | sha256sum
|
||||||
|
techy:REPLACE_WITH_SHA256
|
||||||
|
diceofire:REPLACE_WITH_SHA256
|
||||||
|
# admin may dump everyone's night
|
||||||
|
admin:REPLACE_WITH_SHA256
|
||||||
22
firewall/firewall.sh
Executable file
22
firewall/firewall.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Install + enable the host firewall. Run with sudo from the bundle dir.
|
||||||
|
# Keep your current SSH session open and test a NEW connection before closing it.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "run with sudo"; exit 1; }
|
||||||
|
|
||||||
|
apt-get install -y -q nftables >/dev/null
|
||||||
|
install -m 0644 nftables.conf /etc/nftables.conf
|
||||||
|
|
||||||
|
echo "== syntax check"; nft -c -f /etc/nftables.conf
|
||||||
|
echo "== apply"; nft -f /etc/nftables.conf
|
||||||
|
systemctl enable nftables >/dev/null 2>&1 || true # loads /etc/nftables.conf at boot, before docker
|
||||||
|
|
||||||
|
echo "== active ruleset (vanguard table)"
|
||||||
|
nft list table inet vanguard | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "== listening on public interfaces (should be 22 80 443 1935 2222 3306 8890 22000 only)"
|
||||||
|
ss -tulpnH | awk '$5 !~ /^(127\.|\[::1\])/ {print " "$1, $5}' | sort -u
|
||||||
|
echo
|
||||||
|
echo "Now open a SECOND terminal and confirm: ssh debian@$(hostname -f 2>/dev/null || hostname)"
|
||||||
|
echo "To restrict SSH to your IPs, edit the ssh_allow set in /etc/nftables.conf and re-run: sudo nft -f /etc/nftables.conf"
|
||||||
63
firewall/nftables.conf
Normal file
63
firewall/nftables.conf
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/sbin/nft -f
|
||||||
|
# vanguard-stream host firewall. Own table so (re)applying never disturbs Docker's rules.
|
||||||
|
# Input only: Docker-published ports (1935, 8890, 22000) arrive via Docker's forward chain and are
|
||||||
|
# listed here for the host-network case (Caddy) and clarity. Nothing else should listen publicly.
|
||||||
|
|
||||||
|
destroy table inet vanguard
|
||||||
|
table inet vanguard {
|
||||||
|
set ssh_allow {
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval
|
||||||
|
# leave empty to allow SSH from anywhere (key auth, rate limited); or list CIDRs:
|
||||||
|
# elements = { 203.0.113.0/24, 198.51.100.7 }
|
||||||
|
}
|
||||||
|
|
||||||
|
set db_allow {
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval
|
||||||
|
# MariaDB clients. Leave empty = anywhere (rate limited); or list the bot dev's IPs and then
|
||||||
|
# delete the "tcp dport 3306 ct state new accept" line below to restrict to this set.
|
||||||
|
# elements = { 203.0.113.7 }
|
||||||
|
}
|
||||||
|
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy drop;
|
||||||
|
|
||||||
|
iif lo accept
|
||||||
|
ct state established,related accept
|
||||||
|
ct state invalid drop
|
||||||
|
|
||||||
|
# icmp (ping, pmtu) and all icmpv6 (neighbor discovery is mandatory)
|
||||||
|
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
|
||||||
|
ip6 nexthdr icmpv6 accept
|
||||||
|
|
||||||
|
# dhcp client replies (OVH may hand out the address by DHCP)
|
||||||
|
udp sport 67 udp dport 68 accept
|
||||||
|
|
||||||
|
# ssh: restricted to ssh_allow if it has entries, otherwise anywhere, rate limited
|
||||||
|
tcp dport 22 ct state new ip saddr @ssh_allow accept
|
||||||
|
tcp dport 22 ct state new limit rate over 15/minute burst 10 packets drop
|
||||||
|
tcp dport 22 ct state new accept
|
||||||
|
|
||||||
|
# public services
|
||||||
|
tcp dport { 80, 443 } accept # Caddy: copyparty, dock, dump
|
||||||
|
udp dport 443 accept # Caddy HTTP/3 (QUIC)
|
||||||
|
tcp dport 1935 accept # RTMP ingest
|
||||||
|
udp dport 8890 accept # SRT ingest
|
||||||
|
tcp dport 22000 accept # Syncthing
|
||||||
|
tcp dport 2222 accept # Gitea ssh (git clone/push)
|
||||||
|
udp dport 22000 accept # Syncthing QUIC
|
||||||
|
|
||||||
|
# mariadb (host network): allowlist first, then anywhere with a brute-force limit
|
||||||
|
tcp dport 3306 ct state new ip saddr @db_allow accept
|
||||||
|
tcp dport 3306 ct state new limit rate over 30/minute burst 20 packets drop
|
||||||
|
tcp dport 3306 ct state new accept
|
||||||
|
|
||||||
|
# NOT opened on purpose — these bind to 127.0.0.1 only and are reached via Caddy or an ssh tunnel:
|
||||||
|
# 9000 authentik · 8096 jellyfin · 3923 copyparty · 8384 syncthing UI · 8080 dumpd
|
||||||
|
# Docker publishes them on loopback, so its NAT never forwards outside traffic to them either.
|
||||||
|
|
||||||
|
# everything else: drop (policy), but log a trickle for diagnosis
|
||||||
|
limit rate 5/minute log prefix "vg-drop " level info
|
||||||
|
}
|
||||||
|
}
|
||||||
19
home/rclone-archive.sh
Executable file
19
home/rclone-archive.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run nightly on the HOME box. Pulls originals older than 15 days off the server,
|
||||||
|
# moves originals AND their .src raw files; nothing is left behind.
|
||||||
|
# Setup once: rclone config -> new remote "vanguard", type sftp, host media.example.com, user debian, key file.
|
||||||
|
set -euo pipefail
|
||||||
|
REMOTE=vanguard
|
||||||
|
ARCHIVE=/mnt/archive # local HDD
|
||||||
|
MIN_AGE=15d
|
||||||
|
LOG=/var/log/vanguard-archive.log
|
||||||
|
|
||||||
|
for sub in streams clips; do
|
||||||
|
rclone move "$REMOTE:/srv/media/$sub" "$ARCHIVE/$sub" \
|
||||||
|
--min-age "$MIN_AGE" \
|
||||||
|
--exclude '*.part' --exclude '*.normalize-failed.log' \
|
||||||
|
--transfers 4 --checkers 8 \
|
||||||
|
--delete-empty-src-dirs=false \
|
||||||
|
--log-file "$LOG" --log-level INFO
|
||||||
|
done
|
||||||
|
echo "$(date '+%F %T') archive pull done" >> "$LOG"
|
||||||
83
install.sh
Executable file
83
install.sh
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Debian 13 host installer for vanguard-stream. Run with sudo from this directory.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "run with sudo"; exit 1; }
|
||||||
|
OWNER="${SUDO_USER:-debian}" # the login user; owns /srv/media and may run docker
|
||||||
|
|
||||||
|
echo "== packages"
|
||||||
|
apt-get update -q
|
||||||
|
apt-get install -y -q ca-certificates curl gnupg ffmpeg jq psmisc rclone python3 xfsprogs
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null; then
|
||||||
|
echo "== docker"
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -q
|
||||||
|
apt-get install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== timezone: server clock, logs, timers and the dump window all in Eastern"
|
||||||
|
timedatectl set-timezone "${VG_TZ:-America/New_York}"
|
||||||
|
timedatectl set-ntp true
|
||||||
|
echo " timezone is: $(timedatectl show -p Timezone --value) (override: VG_TZ=... bash install.sh)"
|
||||||
|
|
||||||
|
echo "== docker waits for /srv/media (never record onto the OS disk)"
|
||||||
|
install -d /etc/systemd/system/docker.service.d
|
||||||
|
cat > /etc/systemd/system/docker.service.d/10-srv-media.conf <<'DROP'
|
||||||
|
[Unit]
|
||||||
|
RequiresMountsFor=/srv/media
|
||||||
|
DROP
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart docker 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "== media user + folders"
|
||||||
|
# media files are owned by uid 1000 — on OVH's template that is the 'debian' user
|
||||||
|
getent passwd 1000 >/dev/null || useradd -r -u 1000 -d /srv/media -s /usr/sbin/nologin media
|
||||||
|
usermod -aG docker "$OWNER" 2>/dev/null || true
|
||||||
|
install -d -o 1000 -g 1000 /srv/media/{streams,clips,finals,trash,.markers,.syncthing}
|
||||||
|
install -d -o 1000 -g 1000 /srv/appdata/jellyfin/{config,cache} /srv/appdata/authentik/{media,templates,certs}
|
||||||
|
install -d /srv/appdata/authentik/{db,redis} /srv/appdata/mariadb
|
||||||
|
install -d -o 1000 -g 1000 /srv/appdata/discord-bot /srv/appdata/gitea /srv/appdata/act_runner
|
||||||
|
install -d -o 1000 -g 1000 /srv/media/streams/{techy,diceofire} /srv/media/clips/{techy,diceofire}
|
||||||
|
for d in /srv/media/clips/*/; do
|
||||||
|
[[ -e "$d/.stignore" ]] || install -m 0644 -o 1000 -g 1000 syncthing/stignore "$d/.stignore"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== host scripts + units"
|
||||||
|
install -m 0755 bin/normalize-sweep.sh bin/trash-purge.sh bin/dumpd.py /usr/local/bin/
|
||||||
|
install -d -m 0750 /etc/vanguard
|
||||||
|
install -m 0640 dump-users.txt /etc/vanguard/dump-users.txt
|
||||||
|
install -m 0644 systemd/*.service systemd/*.timer /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now normalize-sweep.timer trash-purge.timer dumpd.service
|
||||||
|
|
||||||
|
echo "== sysctl"
|
||||||
|
cat > /etc/sysctl.d/90-vanguard.conf <<'SYS'
|
||||||
|
vm.swappiness = 10
|
||||||
|
net.core.rmem_max = 16777216
|
||||||
|
net.core.wmem_max = 16777216
|
||||||
|
SYS
|
||||||
|
sysctl -q --system
|
||||||
|
|
||||||
|
echo "== compose images (the stack is started by ./configure.py --full, which generates .env first)"
|
||||||
|
chown -R "$OWNER:" .
|
||||||
|
docker compose pull -q --ignore-buildable 2>/dev/null || docker compose pull -q || true
|
||||||
|
|
||||||
|
echo "== host firewall (nftables; own table, input only; keeps this SSH session alive)"
|
||||||
|
if [[ -f firewall/firewall.sh ]]; then
|
||||||
|
bash firewall/firewall.sh
|
||||||
|
else
|
||||||
|
echo " firewall/firewall.sh missing — skipped"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<'MSG'
|
||||||
|
|
||||||
|
== done. Next: ./configure.py --full (hostnames, passwords, .env secrets; starts the containers)
|
||||||
|
== firewall is active: open a SECOND terminal and confirm ssh still works before closing this one.
|
||||||
|
|
||||||
|
== log out and back in once so the docker group applies; then 'docker compose' works without sudo.
|
||||||
|
== syncthing UI: ssh -L 8384:127.0.0.1:8384 debian@SERVER -> http://localhost:8384
|
||||||
|
MSG
|
||||||
29
installJellyfinPlugin.sh
Executable file
29
installJellyfinPlugin.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Install/upgrade the Vanguard Metadata Jellyfin plugin from jellyfin-plugin-vanguard/dist/*.zip
|
||||||
|
# (built on a dev box with ./package.sh and scp'd up with the bundle). Re-runnable.
|
||||||
|
# Removes older versions of the same plugin so Jellyfin loads exactly one.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
PLUGDIR=/srv/appdata/jellyfin/config/plugins
|
||||||
|
NAME="Vanguard Metadata"
|
||||||
|
|
||||||
|
mkdir -p jellyfin-plugin-vanguard/dist
|
||||||
|
ZIP=$(ls -1 jellyfin-plugin-vanguard/dist/VanguardMetadata_*.zip 2>/dev/null | sort -V | tail -1 || true)
|
||||||
|
[[ -n $ZIP ]] || { echo "no plugin zip in jellyfin-plugin-vanguard/dist/ — build with ./package.sh on the dev box, then:"; echo " scp jellyfin-plugin-vanguard/dist/VanguardMetadata_*.zip debian@HOST:~/vanguard-stream/jellyfin-plugin-vanguard/dist/"; exit 1; }
|
||||||
|
VER=$(basename "$ZIP" .zip | sed 's/^VanguardMetadata_//')
|
||||||
|
DEST="$PLUGDIR/${NAME}_${VER}"
|
||||||
|
|
||||||
|
if [[ -f "$DEST/meta.json" ]] && grep -q "\"version\": \"$VER\"" "$DEST/meta.json"; then
|
||||||
|
echo "plugin $VER already installed at $DEST"; exit 0
|
||||||
|
fi
|
||||||
|
sudo install -d -o 1000 -g 1000 "$PLUGDIR"
|
||||||
|
for old in "$PLUGDIR/${NAME}_"*/; do [[ -d $old && $old != "$DEST/" ]] && { echo "removing old $old"; sudo rm -rf "$old"; }; done
|
||||||
|
sudo -u "#1000" mkdir -p "$DEST"
|
||||||
|
sudo -u "#1000" python3 -m zipfile -e "$ZIP" "$DEST/"
|
||||||
|
echo "installed $NAME $VER -> $DEST"
|
||||||
|
ls -la "$DEST"
|
||||||
|
if docker ps --format '{{.Names}}' | grep -qx jellyfin; then
|
||||||
|
docker compose restart jellyfin
|
||||||
|
sleep 8
|
||||||
|
docker logs jellyfin --tail 60 2>&1 | grep -iE "vanguard|Loaded plugin" | tail -3 || true
|
||||||
|
fi
|
||||||
4
jellyfin-plugin-vanguard/.gitignore
vendored
Normal file
4
jellyfin-plugin-vanguard/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<!-- Pure logic (season policy, timestamp parsing). No Jellyfin dependency so it is unit-testable
|
||||||
|
without the ASP.NET shared runtime. Shipped alongside the plugin DLL. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RootNamespace>Jellyfin.Plugin.Vanguard</RootNamespace>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Seasons;
|
||||||
|
|
||||||
|
/// <summary>Finds the best "when was this recorded" for a media file.</summary>
|
||||||
|
public static partial class ItemTimestamp
|
||||||
|
{
|
||||||
|
public enum Source { Sidecar, Filename, FileTime }
|
||||||
|
|
||||||
|
public sealed record Result(DateTimeOffset Utc, Source Source);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Precedence: clip.json / <name>.json sidecar "timestamp" (UTC ISO-8601, written by the clip bot)
|
||||||
|
/// -> MediaMTX filename yyyy-MM-dd_HH-mm-ss (local time of the server, i.e. <paramref name="tz"/>)
|
||||||
|
/// -> file last-write time.
|
||||||
|
/// </summary>
|
||||||
|
public static Result Resolve(string path, TimeZoneInfo tz)
|
||||||
|
{
|
||||||
|
var sidecar = TryReadSidecar(path);
|
||||||
|
if (sidecar is not null) return new Result(sidecar.Value, Source.Sidecar);
|
||||||
|
|
||||||
|
var fromName = TryParseFilename(Path.GetFileNameWithoutExtension(path), tz);
|
||||||
|
if (fromName is not null) return new Result(fromName.Value, Source.Filename);
|
||||||
|
|
||||||
|
var mtime = File.Exists(path) ? File.GetLastWriteTimeUtc(path) : DateTime.UtcNow;
|
||||||
|
return new Result(new DateTimeOffset(mtime, TimeSpan.Zero), Source.FileTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaMTX 2026-10-29_21-03-11 Xbox Game Bar Game 2026-10-29 21-03-11
|
||||||
|
[GeneratedRegex(@"(?<y>\d{4})-(?<mo>\d{2})-(?<d>\d{2})[_ T](?<h>\d{2})-(?<mi>\d{2})-(?<s>\d{2})")]
|
||||||
|
private static partial Regex DashedName();
|
||||||
|
// ShadowPlay / NVIDIA App Game 2026.10.29 - 21.03.11.05.DVR
|
||||||
|
[GeneratedRegex(@"(?<y>\d{4})\.(?<mo>\d{2})\.(?<d>\d{2}) - (?<h>\d{2})\.(?<mi>\d{2})\.(?<s>\d{2})")]
|
||||||
|
private static partial Regex ShadowPlayName();
|
||||||
|
// Steam Game Recording clip_730_20261029_210311 (also plain 20261029_210311 / 20261029-210311)
|
||||||
|
[GeneratedRegex(@"(?<![\d])(?<y>20\d{2})(?<mo>\d{2})(?<d>\d{2})[_-](?<h>\d{2})(?<mi>\d{2})(?<s>\d{2})(?![\d])")]
|
||||||
|
private static partial Regex CompactName();
|
||||||
|
|
||||||
|
public static DateTimeOffset? TryParseFilename(string stem, TimeZoneInfo tz)
|
||||||
|
{
|
||||||
|
var m = DashedName().Match(stem);
|
||||||
|
if (!m.Success) m = ShadowPlayName().Match(stem);
|
||||||
|
if (!m.Success) m = CompactName().Match(stem);
|
||||||
|
if (!m.Success) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var local = new DateTime(
|
||||||
|
int.Parse(m.Groups["y"].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups["mo"].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups["d"].Value, CultureInfo.InvariantCulture),
|
||||||
|
int.Parse(m.Groups["h"].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups["mi"].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups["s"].Value, CultureInfo.InvariantCulture),
|
||||||
|
DateTimeKind.Unspecified);
|
||||||
|
var offset = tz.GetUtcOffset(local);
|
||||||
|
return new DateTimeOffset(local, offset);
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset? TryReadSidecar(string path)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(path) ?? string.Empty;
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(path);
|
||||||
|
foreach (var candidate in new[] { Path.Combine(dir, stem + ".json"), Path.Combine(dir, stem + ".clip.json") })
|
||||||
|
{
|
||||||
|
if (!File.Exists(candidate)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(File.ReadAllText(candidate));
|
||||||
|
if (doc.RootElement.TryGetProperty("timestamp", out var ts) && ts.ValueKind == JsonValueKind.String &&
|
||||||
|
DateTimeOffset.TryParse(ts.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException) { /* ignore broken sidecar */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Seasons;
|
||||||
|
|
||||||
|
/// <summary>Where an item sits in the rolling season scheme.</summary>
|
||||||
|
public sealed record SeasonSlot(int SeasonNumber, string SeasonName, int EpisodeNumber, DateOnly BroadcastDay, SeasonKind Kind);
|
||||||
|
|
||||||
|
public enum SeasonKind { Day, Week, Month, Year }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure function: (item timestamp, now, settings) -> season/episode numbers and season name.
|
||||||
|
/// Rolling consolidation: items in the current ISO week are bucketed by broadcast day; in the
|
||||||
|
/// current month by ISO week; in the current year by month; older by year. Episode numbers are
|
||||||
|
/// MMDDHHMMSS of the local timestamp, so they never change when the season rolls.
|
||||||
|
/// </summary>
|
||||||
|
public static class SeasonPolicy
|
||||||
|
{
|
||||||
|
/// <summary>Week seasons are 1YYYYWW so they never collide with YYYYMM month seasons. Lengths: year 4, month 6, week 7, day 8.</summary>
|
||||||
|
public const int WeekBase = 1_000_000;
|
||||||
|
|
||||||
|
/// <summary>Recover kind and display name from a season number alone (used by the Season provider).</summary>
|
||||||
|
public static (SeasonKind Kind, string Name)? Describe(int seasonNumber, IReadOnlyDictionary<int, string>? nameOverrides = null)
|
||||||
|
{
|
||||||
|
(SeasonKind, string)? r = seasonNumber switch
|
||||||
|
{
|
||||||
|
>= 10_000_000 and <= 99_999_999 when TryDay(seasonNumber, out var d) => (SeasonKind.Day, d.ToString("dddd, MMM d", CultureInfo.InvariantCulture)),
|
||||||
|
>= WeekBase and < 10_000_000 => (SeasonKind.Week, $"Week {seasonNumber % 100}"),
|
||||||
|
>= 100_000 and <= 999_999 when seasonNumber % 100 is >= 1 and <= 12 => (SeasonKind.Month, new DateOnly(seasonNumber / 100, seasonNumber % 100, 1).ToString("MMMM yyyy", CultureInfo.InvariantCulture)),
|
||||||
|
>= 1000 and <= 9999 => (SeasonKind.Year, seasonNumber.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (r is null) return null;
|
||||||
|
if (nameOverrides is not null && nameOverrides.TryGetValue(seasonNumber, out var over) && !string.IsNullOrWhiteSpace(over))
|
||||||
|
{
|
||||||
|
return (r.Value.Item1, over.Trim());
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDay(int n, out DateOnly d)
|
||||||
|
{
|
||||||
|
try { d = new DateOnly(n / 10000, n / 100 % 100, n % 100); return true; }
|
||||||
|
catch (ArgumentOutOfRangeException) { d = default; return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SeasonSlot Resolve(DateTimeOffset itemUtc, DateTimeOffset nowUtc, TimeZoneInfo tz, int rolloverHour, IReadOnlyDictionary<int, string>? nameOverrides = null)
|
||||||
|
{
|
||||||
|
var item = TimeZoneInfo.ConvertTime(itemUtc, tz);
|
||||||
|
var now = TimeZoneInfo.ConvertTime(nowUtc, tz);
|
||||||
|
var itemDay = BroadcastDay(item, rolloverHour);
|
||||||
|
var today = BroadcastDay(now, rolloverHour);
|
||||||
|
|
||||||
|
var (itemIsoYear, itemIsoWeek) = IsoWeek(itemDay);
|
||||||
|
var (todayIsoYear, todayIsoWeek) = IsoWeek(today);
|
||||||
|
|
||||||
|
int season; string name; SeasonKind kind;
|
||||||
|
if (itemIsoYear == todayIsoYear && itemIsoWeek == todayIsoWeek)
|
||||||
|
{
|
||||||
|
kind = SeasonKind.Day;
|
||||||
|
season = itemDay.Year * 10000 + itemDay.Month * 100 + itemDay.Day; // 20261030
|
||||||
|
name = itemDay.ToString("dddd, MMM d", CultureInfo.InvariantCulture); // Thursday, Oct 30
|
||||||
|
}
|
||||||
|
else if (itemDay.Year == today.Year && itemDay.Month == today.Month)
|
||||||
|
{
|
||||||
|
kind = SeasonKind.Week;
|
||||||
|
season = WeekBase + itemIsoYear * 100 + itemIsoWeek; // 1202644 (leading 1 keeps weeks distinct from months)
|
||||||
|
name = $"Week {itemIsoWeek}";
|
||||||
|
}
|
||||||
|
else if (itemDay.Year == today.Year)
|
||||||
|
{
|
||||||
|
kind = SeasonKind.Month;
|
||||||
|
season = itemDay.Year * 100 + itemDay.Month; // 202610
|
||||||
|
name = itemDay.ToString("MMMM yyyy", CultureInfo.InvariantCulture); // October 2026
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
kind = SeasonKind.Year;
|
||||||
|
season = itemDay.Year; // 2026
|
||||||
|
name = itemDay.Year.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameOverrides is not null && nameOverrides.TryGetValue(season, out var over) && !string.IsNullOrWhiteSpace(over))
|
||||||
|
{
|
||||||
|
name = over.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// MMDDHHMMSS of the local timestamp: 1030210311 -> fits int32 (max 1231235959 < 2147483647)
|
||||||
|
var episode = item.Month * 100000000 + item.Day * 1000000 + item.Hour * 10000 + item.Minute * 100 + item.Second;
|
||||||
|
return new SeasonSlot(season, name, episode, itemDay, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Calendar day the item belongs to after shifting the day boundary to <paramref name="rolloverHour"/>.</summary>
|
||||||
|
public static DateOnly BroadcastDay(DateTimeOffset local, int rolloverHour) =>
|
||||||
|
DateOnly.FromDateTime(local.DateTime.AddHours(-rolloverHour));
|
||||||
|
|
||||||
|
public static (int IsoYear, int IsoWeek) IsoWeek(DateOnly d)
|
||||||
|
{
|
||||||
|
var dt = d.ToDateTime(TimeOnly.MinValue);
|
||||||
|
return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse "202611=Winter Scrims" lines into a map.</summary>
|
||||||
|
public static IReadOnlyDictionary<int, string> ParseOverrides(string? text)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<int, string>();
|
||||||
|
foreach (var raw in (text ?? string.Empty).Split('\n'))
|
||||||
|
{
|
||||||
|
var line = raw.Trim();
|
||||||
|
if (line.Length == 0 || line.StartsWith('#')) continue;
|
||||||
|
var eq = line.IndexOf('=');
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
if (int.TryParse(line[..eq].Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var key))
|
||||||
|
{
|
||||||
|
map[key] = line[(eq + 1)..].Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
|
||||||
|
<PackageReference Include="xunit" Version="2.*" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../Jellyfin.Plugin.Vanguard.Core/Jellyfin.Plugin.Vanguard.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Tests;
|
||||||
|
|
||||||
|
public class SeasonPolicyTests
|
||||||
|
{
|
||||||
|
private static readonly TimeZoneInfo NY = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
|
||||||
|
private static DateTimeOffset Ny(int y, int mo, int d, int h, int mi, int s = 0)
|
||||||
|
{
|
||||||
|
var local = new DateTime(y, mo, d, h, mi, s, DateTimeKind.Unspecified);
|
||||||
|
return new DateTimeOffset(local, NY.GetUtcOffset(local));
|
||||||
|
}
|
||||||
|
|
||||||
|
// "now" = Thursday 2026-10-30 12:00 Eastern (ISO week 44)
|
||||||
|
private static readonly DateTimeOffset Now = Ny(2026, 10, 30, 12, 0);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameWeek_IsDaySeason()
|
||||||
|
{
|
||||||
|
var slot = SeasonPolicy.Resolve(Ny(2026, 10, 29, 21, 3, 11), Now, NY, 4);
|
||||||
|
Assert.Equal(SeasonKind.Day, slot.Kind);
|
||||||
|
Assert.Equal(20261029, slot.SeasonNumber);
|
||||||
|
Assert.Equal("Thursday, Oct 29", slot.SeasonName);
|
||||||
|
Assert.Equal(1029210311, slot.EpisodeNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AfterMidnight_BelongsToPreviousBroadcastDay()
|
||||||
|
{
|
||||||
|
var slot = SeasonPolicy.Resolve(Ny(2026, 10, 30, 1, 15), Now, NY, 4);
|
||||||
|
Assert.Equal(new DateOnly(2026, 10, 29), slot.BroadcastDay);
|
||||||
|
Assert.Equal(20261029, slot.SeasonNumber);
|
||||||
|
Assert.Equal(1030011500, slot.EpisodeNumber); // episode number keeps the real clock time
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EarlierInMonth_IsWeekSeason()
|
||||||
|
{
|
||||||
|
var slot = SeasonPolicy.Resolve(Ny(2026, 10, 14, 20, 0), Now, NY, 4); // ISO week 42
|
||||||
|
Assert.Equal(SeasonKind.Week, slot.Kind);
|
||||||
|
Assert.Equal(1202642, slot.SeasonNumber);
|
||||||
|
Assert.Equal("Week 42", slot.SeasonName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EarlierInYear_IsMonthSeason()
|
||||||
|
{
|
||||||
|
var slot = SeasonPolicy.Resolve(Ny(2026, 7, 4, 20, 0), Now, NY, 4);
|
||||||
|
Assert.Equal(SeasonKind.Month, slot.Kind);
|
||||||
|
Assert.Equal(202607, slot.SeasonNumber);
|
||||||
|
Assert.Equal("July 2026", slot.SeasonName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PreviousYear_IsYearSeason()
|
||||||
|
{
|
||||||
|
var slot = SeasonPolicy.Resolve(Ny(2025, 12, 31, 23, 0), Now, NY, 4);
|
||||||
|
Assert.Equal(SeasonKind.Year, slot.Kind);
|
||||||
|
Assert.Equal(2025, slot.SeasonNumber);
|
||||||
|
Assert.Equal("2025", slot.SeasonName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EpisodeNumber_IsStableAcrossRolls()
|
||||||
|
{
|
||||||
|
var ts = Ny(2026, 10, 29, 21, 3, 11);
|
||||||
|
var e1 = SeasonPolicy.Resolve(ts, Now, NY, 4).EpisodeNumber;
|
||||||
|
var e2 = SeasonPolicy.Resolve(ts, Ny(2026, 11, 20, 12, 0), NY, 4).EpisodeNumber; // weeks later: week bucket? no -> month bucket
|
||||||
|
var e3 = SeasonPolicy.Resolve(ts, Ny(2027, 3, 1, 12, 0), NY, 4).EpisodeNumber; // next year -> year bucket
|
||||||
|
Assert.Equal(e1, e2);
|
||||||
|
Assert.Equal(e1, e3);
|
||||||
|
Assert.Equal(202610, SeasonPolicy.Resolve(ts, Ny(2026, 11, 20, 12, 0), NY, 4).SeasonNumber);
|
||||||
|
Assert.Equal(2026, SeasonPolicy.Resolve(ts, Ny(2027, 3, 1, 12, 0), NY, 4).SeasonNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Overrides_ReplaceGeneratedName()
|
||||||
|
{
|
||||||
|
var over = SeasonPolicy.ParseOverrides("# comment\n202610 = Fall Training Block\nbad line\n");
|
||||||
|
var slot = SeasonPolicy.Resolve(Ny(2026, 10, 14, 20, 0), Ny(2026, 12, 1, 12, 0), NY, 4, over);
|
||||||
|
Assert.Equal("Fall Training Block", slot.SeasonName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DstTransition_UsesCorrectOffset()
|
||||||
|
{
|
||||||
|
// 2026-11-01 02:30 local does not exist twice in a way that breaks us; 01:30 is ambiguous: must not throw
|
||||||
|
var slot = SeasonPolicy.Resolve(Ny(2026, 11, 1, 1, 30), Ny(2026, 11, 2, 12, 0), NY, 4);
|
||||||
|
Assert.Equal(new DateOnly(2026, 10, 31), slot.BroadcastDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Describe_RecoversKindAndName()
|
||||||
|
{
|
||||||
|
Assert.Equal((SeasonKind.Day, "Thursday, Oct 29"), SeasonPolicy.Describe(20261029));
|
||||||
|
Assert.Equal((SeasonKind.Week, "Week 42"), SeasonPolicy.Describe(1202642));
|
||||||
|
Assert.Equal((SeasonKind.Month, "October 2026"), SeasonPolicy.Describe(202610));
|
||||||
|
Assert.Equal((SeasonKind.Year, "2025"), SeasonPolicy.Describe(2025));
|
||||||
|
Assert.Null(SeasonPolicy.Describe(7));
|
||||||
|
Assert.Equal("Fall Block", SeasonPolicy.Describe(202610, new Dictionary<int, string> { [202610] = "Fall Block" })!.Value.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Filename_ParsesMediaMtxPattern()
|
||||||
|
{
|
||||||
|
var ts = ItemTimestamp.TryParseFilename("2026-10-29_21-03-11", NY);
|
||||||
|
Assert.NotNull(ts);
|
||||||
|
Assert.Equal(Ny(2026, 10, 29, 21, 3, 11), ts!.Value);
|
||||||
|
Assert.Null(ItemTimestamp.TryParseFilename("clip-final-v2", NY));
|
||||||
|
Assert.Equal(Ny(2026, 10, 29, 21, 3, 11), ItemTimestamp.TryParseFilename("Overwatch 2 2026.10.29 - 21.03.11.05.DVR", NY));
|
||||||
|
Assert.Equal(Ny(2026, 10, 29, 21, 3, 11), ItemTimestamp.TryParseFilename("Halo Infinite 2026-10-29 21-03-11", NY));
|
||||||
|
Assert.Equal(Ny(2026, 10, 29, 21, 3, 11), ItemTimestamp.TryParseFilename("clip_730_20261029_210311", NY));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Configuration;
|
||||||
|
|
||||||
|
/// <summary>Plugin settings (edited on the plugin's config page).</summary>
|
||||||
|
public class PluginConfiguration : BasePluginConfiguration
|
||||||
|
{
|
||||||
|
// --- Discord -------------------------------------------------------------------------
|
||||||
|
public string DiscordBotToken { get; set; } = string.Empty;
|
||||||
|
public string DiscordGuildId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// --- Discord feed (new stream / new clip notices) ---------------------------------------
|
||||||
|
/// <summary>Channel to post notices in (same guild). Empty = feed off.</summary>
|
||||||
|
public string NotifyChannelId { get; set; } = string.Empty;
|
||||||
|
public bool NotifyNewStreams { get; set; } = true;
|
||||||
|
public bool NotifyNewClips { get; set; } = true;
|
||||||
|
/// <summary>Public Jellyfin URL used in links, e.g. https://watch.example.com</summary>
|
||||||
|
public string PublicUrl { get; set; } = string.Empty;
|
||||||
|
/// <summary>Library names that count as streams / clips (case-insensitive substring match on the library name or path).</summary>
|
||||||
|
public string StreamsLibraryHint { get; set; } = "stream";
|
||||||
|
public string ClipsLibraryHint { get; set; } = "clip";
|
||||||
|
|
||||||
|
// --- Playback policy (no hardware encoder on this box) --------------------------------------
|
||||||
|
/// <summary>Set direct-play-only on all users automatically (new SSO users included). Turn off when the server can transcode.</summary>
|
||||||
|
public bool EnforceDirectPlayOnly { get; set; } = true;
|
||||||
|
public bool EnforceOnAdminsToo { get; set; } = false;
|
||||||
|
|
||||||
|
// --- Authentik (streamer username -> Discord user id via the OAuth source connection) --
|
||||||
|
public string AuthentikUrl { get; set; } = "https://auth.example.com";
|
||||||
|
public string AuthentikApiToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// --- Time / seasons ---------------------------------------------------------------------
|
||||||
|
public string TimeZoneId { get; set; } = "America/New_York";
|
||||||
|
/// <summary>Hour (0-23) at which the "broadcast day" rolls over. 4 = a 01:00 clip belongs to the previous evening.</summary>
|
||||||
|
public int BroadcastDayRolloverHour { get; set; } = 4;
|
||||||
|
/// <summary>Season name overrides: "202611=Winter Scrims Block", one per line.</summary>
|
||||||
|
public string SeasonNameOverrides { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Prefer the overlapping Discord scheduled event's name as the episode title; fallback is the dock title (folder name).</summary>
|
||||||
|
public bool EpisodeTitleFromEvent { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folder name -> identity, one per line: "techy=123456789012345678" (Discord user id) or
|
||||||
|
/// "techy=authentik:someuser" (Authentik username). Folders not listed are looked up in Authentik
|
||||||
|
/// by username = folder name.
|
||||||
|
/// </summary>
|
||||||
|
public string FolderIdentityMap { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Hours of slack when matching a recording to a Discord scheduled event (before start / after end).</summary>
|
||||||
|
public double EventMatchSlackHours { get; set; } = 1.0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><title>Vanguard Metadata</title></head>
|
||||||
|
<body>
|
||||||
|
<div id="VanguardConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
|
||||||
|
<div data-role="content"><div class="content-primary">
|
||||||
|
<form id="VanguardConfigForm">
|
||||||
|
<h2>Discord</h2>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="DiscordBotToken" type="password" label="Bot token" /></div>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="DiscordGuildId" type="text" label="Server (guild) ID" /></div>
|
||||||
|
<h2>Discord feed</h2>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="NotifyChannelId" type="text" label="Channel ID for new stream / clip notices (empty = off)" /></div>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="PublicUrl" type="text" label="Public Jellyfin URL for links (https://watch.example.com)" /></div>
|
||||||
|
<div class="checkboxContainer"><label><input is="emby-checkbox" id="NotifyNewStreams" type="checkbox" /><span>Announce new stream recordings</span></label></div>
|
||||||
|
<div class="checkboxContainer"><label><input is="emby-checkbox" id="NotifyNewClips" type="checkbox" /><span>Announce new clips</span></label></div>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="StreamsLibraryHint" type="text" label="Streams library name contains" /></div>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="ClipsLibraryHint" type="text" label="Clips library name contains" /></div>
|
||||||
|
<h2>Playback policy</h2>
|
||||||
|
<div class="checkboxContainer"><label><input is="emby-checkbox" id="EnforceDirectPlayOnly" type="checkbox" /><span>Enforce direct-play-only (no video transcoding) on all users, every 30 min — turn off once the server has a hardware encoder</span></label></div>
|
||||||
|
<div class="checkboxContainer"><label><input is="emby-checkbox" id="EnforceOnAdminsToo" type="checkbox" /><span>Apply to administrators too</span></label></div>
|
||||||
|
<h2>Authentik</h2>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="AuthentikUrl" type="text" label="Authentik URL" /></div>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="AuthentikApiToken" type="password" label="API token (read-only service account)" /></div>
|
||||||
|
<div class="inputContainer"><textarea is="emby-textarea" id="FolderIdentityMap" rows="4" label="Folder → identity (folder=discordId or folder=authentik:username, one per line; default: Authentik username = folder)"></textarea></div>
|
||||||
|
<h2>Seasons</h2>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="TimeZoneId" type="text" label="Time zone (IANA)" /></div>
|
||||||
|
<div class="inputContainer"><input is="emby-input" id="BroadcastDayRolloverHour" type="number" min="0" max="23" label="Broadcast day rolls over at hour" /></div>
|
||||||
|
<div class="inputContainer"><textarea is="emby-textarea" id="SeasonNameOverrides" rows="4" label="Season name overrides (seasonNumber=Name, one per line)"></textarea></div>
|
||||||
|
<div class="checkboxContainer"><label><input is="emby-checkbox" id="EpisodeTitleFromEvent" type="checkbox" /><span>Episode title from the overlapping Discord event (fallback: dock title)</span></label></div>
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
(function () {
|
||||||
|
var pluginId = "d00ebd97-f987-4678-8089-c8f0f8a6ab3e";
|
||||||
|
var fields = ["DiscordBotToken","DiscordGuildId","AuthentikUrl","AuthentikApiToken","TimeZoneId","BroadcastDayRolloverHour","SeasonNameOverrides","FolderIdentityMap","NotifyChannelId","PublicUrl","StreamsLibraryHint","ClipsLibraryHint"];
|
||||||
|
var checks = ["EpisodeTitleFromEvent","NotifyNewStreams","NotifyNewClips","EnforceDirectPlayOnly","EnforceOnAdminsToo"];
|
||||||
|
document.querySelector('#VanguardConfigPage').addEventListener('pageshow', function () {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(pluginId).then(function (c) {
|
||||||
|
fields.forEach(function (f) { document.querySelector('#' + f).value = c[f] == null ? '' : c[f]; });
|
||||||
|
checks.forEach(function (f) { document.querySelector('#' + f).checked = !!c[f]; });
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelector('#VanguardConfigForm').addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault(); Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(pluginId).then(function (c) {
|
||||||
|
fields.forEach(function (f) { c[f] = document.querySelector('#' + f).value; });
|
||||||
|
c.BroadcastDayRolloverHour = parseInt(c.BroadcastDayRolloverHour, 10) || 4;
|
||||||
|
checks.forEach(function (f) { c[f] = document.querySelector('#' + f).checked; });
|
||||||
|
ApiClient.updatePluginConfiguration(pluginId, c).then(Dashboard.processPluginConfigurationUpdateResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RootNamespace>Jellyfin.Plugin.Vanguard</RootNamespace>
|
||||||
|
<AssemblyVersion>0.1.1.0</AssemblyVersion>
|
||||||
|
<FileVersion>0.1.1.0</FileVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="12.4.0" ExcludeAssets="runtime" />
|
||||||
|
<PackageReference Include="Jellyfin.Model" Version="12.4.0" ExcludeAssets="runtime" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../Jellyfin.Plugin.Vanguard.Core/Jellyfin.Plugin.Vanguard.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="Configuration\configPage.html" />
|
||||||
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
33
jellyfin-plugin-vanguard/Jellyfin.Plugin.Vanguard/Plugin.cs
Normal file
33
jellyfin-plugin-vanguard/Jellyfin.Plugin.Vanguard/Plugin.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Common.Plugins;
|
||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
using MediaBrowser.Model.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard;
|
||||||
|
|
||||||
|
/// <summary>Vanguard Metadata plugin entry point.</summary>
|
||||||
|
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||||
|
{
|
||||||
|
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||||
|
: base(applicationPaths, xmlSerializer)
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string Name => "Vanguard Metadata";
|
||||||
|
|
||||||
|
public override Guid Id => Guid.Parse("d00ebd97-f987-4678-8089-c8f0f8a6ab3e");
|
||||||
|
|
||||||
|
public static Plugin? Instance { get; private set; }
|
||||||
|
|
||||||
|
public IEnumerable<PluginPageInfo> GetPages() =>
|
||||||
|
[
|
||||||
|
new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = Name,
|
||||||
|
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Providers;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Providers;
|
||||||
|
|
||||||
|
/// <summary>Season/episode numbers from the timestamp; title from the Discord event or the dock title.</summary>
|
||||||
|
public sealed class VanguardEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder
|
||||||
|
{
|
||||||
|
private readonly LibraryPaths _paths;
|
||||||
|
private readonly EventStore _store;
|
||||||
|
private readonly IdentityResolver _identity;
|
||||||
|
private readonly IHttpClientFactory _http;
|
||||||
|
private readonly ILogger<VanguardEpisodeProvider> _log;
|
||||||
|
|
||||||
|
public VanguardEpisodeProvider(LibraryPaths paths, EventStore store, IdentityResolver identity, IHttpClientFactory http, ILogger<VanguardEpisodeProvider> log)
|
||||||
|
{ _paths = paths; _store = store; _identity = identity; _http = http; _log = log; }
|
||||||
|
|
||||||
|
public string Name => "Vanguard Metadata";
|
||||||
|
public int Order => 0;
|
||||||
|
|
||||||
|
private static PluginConfiguration Cfg => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
|
||||||
|
public static TimeZoneInfo Tz(PluginConfiguration cfg)
|
||||||
|
{
|
||||||
|
try { return TimeZoneInfo.FindSystemTimeZoneById(cfg.TimeZoneId); }
|
||||||
|
catch (TimeZoneNotFoundException) { return TimeZoneInfo.Utc; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new MetadataResult<Episode>();
|
||||||
|
if (string.IsNullOrEmpty(info.Path)) return result;
|
||||||
|
var cfg = Cfg;
|
||||||
|
var tz = Tz(cfg);
|
||||||
|
var when = ItemTimestamp.Resolve(info.Path, tz);
|
||||||
|
var slot = SeasonPolicy.Resolve(when.Utc, DateTimeOffset.UtcNow, tz, cfg.BroadcastDayRolloverHour, SeasonPolicy.ParseOverrides(cfg.SeasonNameOverrides));
|
||||||
|
var parts = _paths.Split(info.Path);
|
||||||
|
|
||||||
|
var ev = _store.FindEventAt(when.Utc, TimeSpan.FromHours(cfg.EventMatchSlackHours), TimeSpan.FromHours(4));
|
||||||
|
var local = TimeZoneInfo.ConvertTime(when.Utc, tz);
|
||||||
|
var dockTitle = parts?.Title ?? parts?.Stem ?? Path.GetFileNameWithoutExtension(info.Path);
|
||||||
|
var title = cfg.EpisodeTitleFromEvent && ev is not null ? ev.Name : dockTitle;
|
||||||
|
|
||||||
|
var who = parts is null ? null : await _identity.ResolveFolderAsync(parts.SeriesFolder, ct).ConfigureAwait(false);
|
||||||
|
var lines = new List<string>();
|
||||||
|
if (ev is not null && !string.IsNullOrWhiteSpace(ev.Description)) lines.Add(ev.Description!.Trim());
|
||||||
|
lines.Add($"Recorded {local:dddd, MMMM d yyyy 'at' h:mm tt} by {who?.DisplayName ?? parts?.SeriesFolder ?? "unknown"}.");
|
||||||
|
if (ev is not null && title != dockTitle && !string.IsNullOrWhiteSpace(dockTitle)) lines.Add($"Stream title: {dockTitle}");
|
||||||
|
|
||||||
|
result.Item = new Episode
|
||||||
|
{
|
||||||
|
Name = $"{title} — {local:MMM d, h:mm tt}",
|
||||||
|
IndexNumber = slot.EpisodeNumber,
|
||||||
|
ParentIndexNumber = slot.SeasonNumber,
|
||||||
|
PremiereDate = when.Utc.UtcDateTime,
|
||||||
|
ProductionYear = local.Year,
|
||||||
|
Overview = string.Join("\n\n", lines),
|
||||||
|
};
|
||||||
|
result.HasMetadata = true;
|
||||||
|
_log.LogDebug("Episode {Path}: season {Season} ({Name}) episode {Ep} from {Src}", info.Path, slot.SeasonNumber, slot.SeasonName, slot.EpisodeNumber, when.Source);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IEnumerable<RemoteSearchResult>>([]);
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken ct) =>
|
||||||
|
_http.CreateClient(MediaBrowser.Common.Net.NamedClient.Default).GetAsync(url, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Providers;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Providers;
|
||||||
|
|
||||||
|
/// <summary>Names a season from its number: "Thursday, Oct 29", "Week 44", "October 2026", "2026" or an override.</summary>
|
||||||
|
public sealed class VanguardSeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo>, IHasOrder
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _http;
|
||||||
|
public VanguardSeasonProvider(IHttpClientFactory http) { _http = http; }
|
||||||
|
|
||||||
|
public string Name => "Vanguard Metadata";
|
||||||
|
public int Order => 0;
|
||||||
|
|
||||||
|
public Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new MetadataResult<Season>();
|
||||||
|
if (!info.IndexNumber.HasValue) return Task.FromResult(result);
|
||||||
|
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
var d = SeasonPolicy.Describe(info.IndexNumber.Value, SeasonPolicy.ParseOverrides(cfg.SeasonNameOverrides));
|
||||||
|
if (d is null) return Task.FromResult(result);
|
||||||
|
result.Item = new Season { Name = d.Value.Name, IndexNumber = info.IndexNumber };
|
||||||
|
result.HasMetadata = true;
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IEnumerable<RemoteSearchResult>>([]);
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken ct) =>
|
||||||
|
_http.CreateClient(MediaBrowser.Common.Net.NamedClient.Default).GetAsync(url, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Providers;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Providers;
|
||||||
|
|
||||||
|
/// <summary>A show is a streamer/submitter folder: display name and overview from Discord.</summary>
|
||||||
|
public sealed class VanguardSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder
|
||||||
|
{
|
||||||
|
private readonly IdentityResolver _identity;
|
||||||
|
private readonly IHttpClientFactory _http;
|
||||||
|
public VanguardSeriesProvider(IdentityResolver identity, IHttpClientFactory http) { _identity = identity; _http = http; }
|
||||||
|
|
||||||
|
public string Name => "Vanguard Metadata";
|
||||||
|
public int Order => 0;
|
||||||
|
|
||||||
|
public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new MetadataResult<Series>();
|
||||||
|
var folder = string.IsNullOrEmpty(info.Path) ? info.Name : Path.GetFileName(info.Path.TrimEnd(Path.DirectorySeparatorChar));
|
||||||
|
if (string.IsNullOrEmpty(folder)) return result;
|
||||||
|
var m = await _identity.ResolveFolderAsync(folder, ct).ConfigureAwait(false);
|
||||||
|
result.Item = new Series
|
||||||
|
{
|
||||||
|
Name = m?.DisplayName ?? folder,
|
||||||
|
Overview = m is null ? $"Recordings from {folder}." : $"Recordings from {m.DisplayName} (@{m.Username} on Discord).",
|
||||||
|
Status = SeriesStatus.Continuing,
|
||||||
|
};
|
||||||
|
result.HasMetadata = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IEnumerable<RemoteSearchResult>>([]);
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken ct) =>
|
||||||
|
_http.CreateClient(MediaBrowser.Common.Net.NamedClient.Default).GetAsync(url, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Series poster = the Discord avatar (guild-specific if set).</summary>
|
||||||
|
public sealed class VanguardSeriesImageProvider : IRemoteImageProvider, IHasOrder
|
||||||
|
{
|
||||||
|
private readonly IdentityResolver _identity;
|
||||||
|
private readonly IHttpClientFactory _http;
|
||||||
|
public VanguardSeriesImageProvider(IdentityResolver identity, IHttpClientFactory http) { _identity = identity; _http = http; }
|
||||||
|
|
||||||
|
public string Name => "Vanguard Metadata";
|
||||||
|
public int Order => 0;
|
||||||
|
|
||||||
|
public bool Supports(BaseItem item) => item is Series;
|
||||||
|
public IEnumerable<ImageType> GetSupportedImages(BaseItem item) => [ImageType.Primary, ImageType.Thumb];
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var folder = Path.GetFileName(item.Path?.TrimEnd(Path.DirectorySeparatorChar) ?? string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(folder)) return [];
|
||||||
|
var m = await _identity.ResolveFolderAsync(folder, ct).ConfigureAwait(false);
|
||||||
|
if (m?.AvatarUrl is null) return [];
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new RemoteImageInfo { ProviderName = Name, Type = ImageType.Primary, Url = m.AvatarUrl, Width = 512, Height = 512 },
|
||||||
|
new RemoteImageInfo { ProviderName = Name, Type = ImageType.Thumb, Url = m.AvatarUrl, Width = 512, Height = 512 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken ct) =>
|
||||||
|
_http.CreateClient(MediaBrowser.Common.Net.NamedClient.Default).GetAsync(url, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard;
|
||||||
|
|
||||||
|
/// <summary>Registers the plugin's services with Jellyfin's DI container.</summary>
|
||||||
|
public sealed class ServiceRegistrator : IPluginServiceRegistrator
|
||||||
|
{
|
||||||
|
public void RegisterServices(IServiceCollection services, IServerApplicationHost applicationHost)
|
||||||
|
{
|
||||||
|
services.AddSingleton<DiscordClient>();
|
||||||
|
services.AddSingleton<AuthentikClient>();
|
||||||
|
services.AddSingleton<LibraryPaths>();
|
||||||
|
services.AddSingleton<IdentityResolver>();
|
||||||
|
services.AddHostedService<FeedService>();
|
||||||
|
services.AddSingleton(sp => new EventStore(Plugin.Instance?.DataFolderPath ?? Path.Combine(Path.GetTempPath(), "vanguard")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using MediaBrowser.Common.Net;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
|
||||||
|
/// <summary>Resolves an Authentik username to the Discord user id stored on their OAuth source connection.</summary>
|
||||||
|
public sealed class AuthentikClient
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _http;
|
||||||
|
private readonly ILogger<AuthentikClient> _log;
|
||||||
|
|
||||||
|
public AuthentikClient(IHttpClientFactory http, ILogger<AuthentikClient> log) { _http = http; _log = log; }
|
||||||
|
|
||||||
|
public async Task<string?> GetDiscordIdForUsernameAsync(string baseUrl, string token, string username, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(token)) return null;
|
||||||
|
var c = _http.CreateClient(NamedClient.Default);
|
||||||
|
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
var b = baseUrl.TrimEnd('/');
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var u = await c.GetAsync($"{b}/api/v3/core/users/?username={Uri.EscapeDataString(username)}", ct).ConfigureAwait(false);
|
||||||
|
if (!u.IsSuccessStatusCode) { _log.LogWarning("Authentik users lookup {User}: {Status}", username, u.StatusCode); return null; }
|
||||||
|
using var ud = JsonDocument.Parse(await u.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||||
|
var results = ud.RootElement.GetProperty("results");
|
||||||
|
if (results.GetArrayLength() == 0) return null;
|
||||||
|
var pk = results[0].GetProperty("pk").GetInt32();
|
||||||
|
|
||||||
|
using var cres = await c.GetAsync($"{b}/api/v3/sources/user_connections/oauth/?user={pk}", ct).ConfigureAwait(false);
|
||||||
|
if (!cres.IsSuccessStatusCode) { _log.LogWarning("Authentik connections lookup {User}: {Status}", username, cres.StatusCode); return null; }
|
||||||
|
using var cd = JsonDocument.Parse(await cres.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||||
|
foreach (var conn in cd.RootElement.GetProperty("results").EnumerateArray())
|
||||||
|
{
|
||||||
|
var slug = conn.TryGetProperty("source", out var src) && src.ValueKind == JsonValueKind.Object && src.TryGetProperty("slug", out var sl) ? sl.GetString() : null;
|
||||||
|
var ident = conn.TryGetProperty("identifier", out var idp) ? idp.GetString() : null;
|
||||||
|
if (!string.IsNullOrEmpty(ident) && (slug is null || slug.Contains("discord", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return ident;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "Authentik lookup failed for {User}", username);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using MediaBrowser.Common.Net;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
|
||||||
|
public sealed record DiscordMember(string UserId, string Username, string DisplayName, string? AvatarUrl);
|
||||||
|
public sealed record DiscordEvent(string Id, string Name, string? Description, DateTimeOffset Start, DateTimeOffset? End, string? Status, DateTimeOffset SeenAt);
|
||||||
|
|
||||||
|
/// <summary>Thin REST client for the Discord bot API (no gateway). Uses the same application Authentik uses; add a bot user to it.</summary>
|
||||||
|
public sealed class DiscordClient
|
||||||
|
{
|
||||||
|
private const string Api = "https://discord.com/api/v10";
|
||||||
|
private readonly IHttpClientFactory _http;
|
||||||
|
private readonly ILogger<DiscordClient> _log;
|
||||||
|
|
||||||
|
public DiscordClient(IHttpClientFactory http, ILogger<DiscordClient> log) { _http = http; _log = log; }
|
||||||
|
|
||||||
|
private HttpClient Client(string token)
|
||||||
|
{
|
||||||
|
var c = _http.CreateClient(NamedClient.Default);
|
||||||
|
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bot", token);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DiscordMember?> GetGuildMemberAsync(string token, string guildId, string userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var resp = await Client(token).GetAsync($"{Api}/guilds/{guildId}/members/{userId}", ct).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_log.LogWarning("Discord member lookup {User} failed: {Status}", userId, resp.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var user = root.GetProperty("user");
|
||||||
|
var username = user.GetProperty("username").GetString() ?? userId;
|
||||||
|
var global = user.TryGetProperty("global_name", out var g) && g.ValueKind == JsonValueKind.String ? g.GetString() : null;
|
||||||
|
var nick = root.TryGetProperty("nick", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null;
|
||||||
|
var guildAvatar = root.TryGetProperty("avatar", out var ga) && ga.ValueKind == JsonValueKind.String ? ga.GetString() : null;
|
||||||
|
var userAvatar = user.TryGetProperty("avatar", out var ua) && ua.ValueKind == JsonValueKind.String ? ua.GetString() : null;
|
||||||
|
string? avatarUrl = guildAvatar is not null
|
||||||
|
? $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{guildAvatar}.png?size=512"
|
||||||
|
: userAvatar is not null ? $"https://cdn.discordapp.com/avatars/{userId}/{userAvatar}.png?size=512"
|
||||||
|
: $"https://cdn.discordapp.com/embed/avatars/{(ulong.TryParse(userId, out var id) ? (id >> 22) % 6 : 0)}.png";
|
||||||
|
return new DiscordMember(userId, username, nick ?? global ?? username, avatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Post an embed to a channel. <paramref name="fields"/> are (name, value) pairs.</summary>
|
||||||
|
public async Task<bool> PostEmbedAsync(string token, string channelId, string title, string? description, string? url, string? thumbnailUrl, IEnumerable<(string Name, string Value)> fields, int color, CancellationToken ct, byte[]? imageBytes = null, string imageName = "thumb.jpg")
|
||||||
|
{
|
||||||
|
var embed = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["title"] = title.Length > 256 ? title[..256] : title,
|
||||||
|
["description"] = description is { Length: > 0 } ? (description.Length > 2000 ? description[..2000] : description) : null,
|
||||||
|
["url"] = url,
|
||||||
|
["color"] = color,
|
||||||
|
["timestamp"] = DateTimeOffset.UtcNow.ToString("o"),
|
||||||
|
["fields"] = fields.Select(f => new { name = f.Name, value = f.Value, inline = true }).ToArray(),
|
||||||
|
};
|
||||||
|
if (thumbnailUrl is not null) embed["thumbnail"] = new { url = thumbnailUrl };
|
||||||
|
if (imageBytes is { Length: > 0 }) embed["image"] = new { url = $"attachment://{imageName}" }; // big picture = the frame grab
|
||||||
|
var payload = JsonSerializer.Serialize(new { embeds = new[] { embed } }, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||||
|
|
||||||
|
HttpContent content;
|
||||||
|
if (imageBytes is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var mp = new MultipartFormDataContent();
|
||||||
|
mp.Add(new StringContent(payload, System.Text.Encoding.UTF8, "application/json"), "payload_json");
|
||||||
|
var file = new ByteArrayContent(imageBytes);
|
||||||
|
file.Headers.ContentType = new MediaTypeHeaderValue(imageName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" : "image/jpeg");
|
||||||
|
mp.Add(file, "files[0]", imageName);
|
||||||
|
content = mp;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
||||||
|
}
|
||||||
|
using var _ = content;
|
||||||
|
using var resp = await Client(token).PostAsync($"{Api}/channels/{channelId}/messages", content, ct).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode) _log.LogWarning("Discord post to {Channel} failed: {Status} {Body}", channelId, resp.StatusCode, await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||||
|
return resp.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DiscordEvent>> GetScheduledEventsAsync(string token, string guildId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var resp = await Client(token).GetAsync($"{Api}/guilds/{guildId}/scheduled-events", ct).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_log.LogWarning("Discord scheduled-events failed: {Status}", resp.StatusCode);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||||
|
var list = new List<DiscordEvent>();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
foreach (var e in doc.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var start = e.GetProperty("scheduled_start_time").GetDateTimeOffset();
|
||||||
|
DateTimeOffset? end = e.TryGetProperty("scheduled_end_time", out var et) && et.ValueKind == JsonValueKind.String ? et.GetDateTimeOffset() : null;
|
||||||
|
list.Add(new DiscordEvent(
|
||||||
|
e.GetProperty("id").GetString()!,
|
||||||
|
e.GetProperty("name").GetString() ?? "(event)",
|
||||||
|
e.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null,
|
||||||
|
start, end,
|
||||||
|
e.TryGetProperty("status", out var st) ? st.ToString() : null,
|
||||||
|
now));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistent history of Discord scheduled events (Discord only returns upcoming/active ones, so we keep
|
||||||
|
/// everything we have ever seen) plus an in-memory member cache.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EventStore
|
||||||
|
{
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private readonly string _path;
|
||||||
|
private Dictionary<string, DiscordEvent> _events = new();
|
||||||
|
private readonly Dictionary<string, (DiscordMember Member, DateTimeOffset At)> _members = new();
|
||||||
|
private readonly Dictionary<string, (string? DiscordId, DateTimeOffset At)> _identities = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public EventStore(string dataFolder)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dataFolder);
|
||||||
|
_path = Path.Combine(dataFolder, "events.json");
|
||||||
|
Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_path))
|
||||||
|
{
|
||||||
|
var list = JsonSerializer.Deserialize<List<DiscordEvent>>(File.ReadAllText(_path)) ?? [];
|
||||||
|
_events = list.ToDictionary(e => e.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is JsonException or IOException) { _events = new(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Merge(IEnumerable<DiscordEvent> fresh)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
foreach (var e in fresh) _events[e.Id] = e;
|
||||||
|
var tmp = _path + ".tmp";
|
||||||
|
File.WriteAllText(tmp, JsonSerializer.Serialize(_events.Values.OrderBy(e => e.Start).ToList(), new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
File.Move(tmp, _path, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Event whose [start - slack, end + slack] window contains <paramref name="at"/>; closest start wins.</summary>
|
||||||
|
public DiscordEvent? FindEventAt(DateTimeOffset at, TimeSpan slack, TimeSpan defaultDuration)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _events.Values
|
||||||
|
.Where(e => at >= e.Start - slack && at <= (e.End ?? e.Start + defaultDuration) + slack)
|
||||||
|
.OrderBy(e => Math.Abs((e.Start - at).Ticks))
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Count { get { lock (_lock) return _events.Count; } }
|
||||||
|
|
||||||
|
public DiscordMember? GetCachedMember(string discordId, TimeSpan maxAge)
|
||||||
|
{
|
||||||
|
lock (_lock) return _members.TryGetValue(discordId, out var m) && DateTimeOffset.UtcNow - m.At < maxAge ? m.Member : null;
|
||||||
|
}
|
||||||
|
public void CacheMember(DiscordMember m) { lock (_lock) _members[m.UserId] = (m, DateTimeOffset.UtcNow); }
|
||||||
|
|
||||||
|
public bool TryGetCachedIdentity(string folder, TimeSpan maxAge, out string? discordId)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_identities.TryGetValue(folder, out var v) && DateTimeOffset.UtcNow - v.At < maxAge) { discordId = v.DiscordId; return true; }
|
||||||
|
discordId = null; return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public void CacheIdentity(string folder, string? discordId) { lock (_lock) _identities[folder] = (discordId, DateTimeOffset.UtcNow); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Providers;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
|
||||||
|
/// <summary>Posts "new stream recording" / "new clip" embeds to a Discord channel when items land in the library.</summary>
|
||||||
|
public sealed class FeedService : IHostedService
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _library;
|
||||||
|
private readonly DiscordClient _discord;
|
||||||
|
private readonly IdentityResolver _identity;
|
||||||
|
private readonly LibraryPaths _paths;
|
||||||
|
private readonly ILogger<FeedService> _log;
|
||||||
|
private readonly HashSet<Guid> _announced = new();
|
||||||
|
|
||||||
|
public FeedService(ILibraryManager library, DiscordClient discord, IdentityResolver identity, LibraryPaths paths, ILogger<FeedService> log)
|
||||||
|
{ _library = library; _discord = discord; _identity = identity; _paths = paths; _log = log; }
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken ct) { _library.ItemAdded += OnItemAdded; return Task.CompletedTask; }
|
||||||
|
public Task StopAsync(CancellationToken ct) { _library.ItemAdded -= OnItemAdded; return Task.CompletedTask; }
|
||||||
|
|
||||||
|
private void OnItemAdded(object? sender, ItemChangeEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Item is not Episode ep || string.IsNullOrEmpty(ep.Path)) return;
|
||||||
|
_ = Task.Run(() => AnnounceAsync(ep));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AnnounceAsync(Episode ep)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.NotifyChannelId) || string.IsNullOrWhiteSpace(cfg.DiscordBotToken)) return;
|
||||||
|
lock (_announced) { if (!_announced.Add(ep.Id)) return; }
|
||||||
|
|
||||||
|
var kind = Classify(ep.Path, cfg);
|
||||||
|
if (kind == Kind.Other) return;
|
||||||
|
if (kind == Kind.Stream && !cfg.NotifyNewStreams) return;
|
||||||
|
if (kind == Kind.Clip && !cfg.NotifyNewClips) return;
|
||||||
|
|
||||||
|
var parts = _paths.Split(ep.Path);
|
||||||
|
var who = parts is null ? null : await _identity.ResolveFolderAsync(parts.SeriesFolder, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
var tz = VanguardEpisodeProvider.Tz(cfg);
|
||||||
|
var when = TimeZoneInfo.ConvertTime(ItemTimestamp.Resolve(ep.Path, tz).Utc, tz);
|
||||||
|
var title = parts?.Title ?? parts?.Stem ?? Path.GetFileNameWithoutExtension(ep.Path);
|
||||||
|
var link = string.IsNullOrWhiteSpace(cfg.PublicUrl) ? null : $"{cfg.PublicUrl.TrimEnd('/')}/web/#/details?id={ep.Id:N}";
|
||||||
|
var name = who?.DisplayName ?? parts?.SeriesFolder ?? "someone";
|
||||||
|
|
||||||
|
var heading = kind == Kind.Stream ? $"🔴 {name} is recording: {title}" : $"🎬 New clip from {name}: {title}";
|
||||||
|
var fields = new List<(string, string)> { ("When", when.ToString("ddd MMM d, h:mm tt", CultureInfo.InvariantCulture)) };
|
||||||
|
if (parts?.Title is not null && kind == Kind.Clip) fields.Add(("Folder", parts.Title));
|
||||||
|
|
||||||
|
// Jellyfin's Screen Grabber extracts the episode's primary image during the refresh that follows
|
||||||
|
// ItemAdded; wait a little for it so the embed can carry the frame. Falls back to avatar only.
|
||||||
|
var (bytes, imgName) = await WaitForPrimaryImageAsync(ep.Id, TimeSpan.FromSeconds(90)).ConfigureAwait(false);
|
||||||
|
await _discord.PostEmbedAsync(cfg.DiscordBotToken, cfg.NotifyChannelId, heading, null, link, who?.AvatarUrl, fields,
|
||||||
|
kind == Kind.Stream ? 0xE74C3C : 0x3D8BFD, CancellationToken.None, bytes, imgName).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _log.LogWarning(ex, "Feed announce failed for {Path}", ep.Path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(byte[]? Bytes, string Name)> WaitForPrimaryImageAsync(Guid id, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||||
|
while (DateTimeOffset.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (_library.GetItemById(id) is BaseItem fresh && fresh.HasImage(ImageType.Primary))
|
||||||
|
{
|
||||||
|
var path = fresh.GetImagePath(ImageType.Primary, 0);
|
||||||
|
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||||
|
{
|
||||||
|
try { return (await File.ReadAllBytesAsync(path).ConfigureAwait(false), "thumb" + Path.GetExtension(path)); }
|
||||||
|
catch (IOException) { /* still being written; retry */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return (null, "thumb.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Kind { Stream, Clip, Other }
|
||||||
|
|
||||||
|
private Kind Classify(string path, PluginConfiguration cfg)
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(path);
|
||||||
|
foreach (var vf in _library.GetVirtualFolders())
|
||||||
|
{
|
||||||
|
var hit = (vf.Locations ?? []).Any(l => full.StartsWith(Path.GetFullPath(l).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.Ordinal));
|
||||||
|
if (!hit) continue;
|
||||||
|
var hay = (vf.Name ?? string.Empty) + " " + string.Join(' ', vf.Locations ?? []);
|
||||||
|
if (!string.IsNullOrWhiteSpace(cfg.StreamsLibraryHint) && hay.Contains(cfg.StreamsLibraryHint, StringComparison.OrdinalIgnoreCase)) return Kind.Stream;
|
||||||
|
if (!string.IsNullOrWhiteSpace(cfg.ClipsLibraryHint) && hay.Contains(cfg.ClipsLibraryHint, StringComparison.OrdinalIgnoreCase)) return Kind.Clip;
|
||||||
|
}
|
||||||
|
return Kind.Other;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
|
||||||
|
/// <summary>Folder name (streamer / submitter) -> Discord member, via the config map or Authentik.</summary>
|
||||||
|
public sealed class IdentityResolver
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MemberTtl = TimeSpan.FromHours(6);
|
||||||
|
private static readonly TimeSpan IdentityTtl = TimeSpan.FromHours(24);
|
||||||
|
|
||||||
|
private readonly DiscordClient _discord;
|
||||||
|
private readonly AuthentikClient _authentik;
|
||||||
|
private readonly EventStore _store;
|
||||||
|
private readonly ILogger<IdentityResolver> _log;
|
||||||
|
|
||||||
|
public IdentityResolver(DiscordClient discord, AuthentikClient authentik, EventStore store, ILogger<IdentityResolver> log)
|
||||||
|
{ _discord = discord; _authentik = authentik; _store = store; _log = log; }
|
||||||
|
|
||||||
|
private static PluginConfiguration Cfg => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
|
||||||
|
public async Task<DiscordMember?> ResolveFolderAsync(string folderName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = Cfg;
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.DiscordBotToken) || string.IsNullOrWhiteSpace(cfg.DiscordGuildId)) return null;
|
||||||
|
|
||||||
|
if (!_store.TryGetCachedIdentity(folderName, IdentityTtl, out var discordId))
|
||||||
|
{
|
||||||
|
discordId = await LookupDiscordIdAsync(cfg, folderName, ct).ConfigureAwait(false);
|
||||||
|
_store.CacheIdentity(folderName, discordId);
|
||||||
|
}
|
||||||
|
if (discordId is null) return null;
|
||||||
|
|
||||||
|
var cached = _store.GetCachedMember(discordId, MemberTtl);
|
||||||
|
if (cached is not null) return cached;
|
||||||
|
var m = await _discord.GetGuildMemberAsync(cfg.DiscordBotToken, cfg.DiscordGuildId, discordId, ct).ConfigureAwait(false);
|
||||||
|
if (m is not null) _store.CacheMember(m);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> LookupDiscordIdAsync(PluginConfiguration cfg, string folder, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string authentikUser = folder;
|
||||||
|
foreach (var raw in (cfg.FolderIdentityMap ?? string.Empty).Split('\n'))
|
||||||
|
{
|
||||||
|
var line = raw.Trim();
|
||||||
|
var eq = line.IndexOf('=');
|
||||||
|
if (eq <= 0 || !line[..eq].Trim().Equals(folder, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
var val = line[(eq + 1)..].Trim();
|
||||||
|
if (val.StartsWith("authentik:", StringComparison.OrdinalIgnoreCase)) { authentikUser = val["authentik:".Length..].Trim(); break; }
|
||||||
|
if (val.All(char.IsDigit) && val.Length >= 15) return val; // a Discord snowflake
|
||||||
|
}
|
||||||
|
var id = await _authentik.GetDiscordIdForUsernameAsync(cfg.AuthentikUrl, cfg.AuthentikApiToken, authentikUser, ct).ConfigureAwait(false);
|
||||||
|
if (id is null) _log.LogInformation("No Discord identity for folder {Folder} (authentik user {User})", folder, authentikUser);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
|
||||||
|
/// <summary>Splits a media path into (series folder, dock title or null, file stem) relative to its library root.</summary>
|
||||||
|
public sealed class LibraryPaths
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _library;
|
||||||
|
public LibraryPaths(ILibraryManager library) { _library = library; }
|
||||||
|
|
||||||
|
public sealed record Parts(string SeriesFolder, string? Title, string Stem);
|
||||||
|
|
||||||
|
public Parts? Split(string path)
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(path);
|
||||||
|
string? root = null;
|
||||||
|
foreach (var vf in _library.GetVirtualFolders())
|
||||||
|
{
|
||||||
|
foreach (var loc in vf.Locations ?? [])
|
||||||
|
{
|
||||||
|
var l = Path.GetFullPath(loc).TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
if (full.StartsWith(l + Path.DirectorySeparatorChar, StringComparison.Ordinal) && (root is null || l.Length > root.Length)) root = l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (root is null) return null;
|
||||||
|
var rel = full[(root.Length + 1)..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (rel.Length < 2) return null;
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(rel[^1]);
|
||||||
|
return rel.Length >= 3 ? new Parts(rel[0], rel[^2], stem) : new Parts(rel[0], null, stem);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Services;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Tasks;
|
||||||
|
|
||||||
|
/// <summary>Every 5 minutes: pull the guild's scheduled events into the persistent history.</summary>
|
||||||
|
public sealed class DiscordSnapshotTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly DiscordClient _discord;
|
||||||
|
private readonly EventStore _store;
|
||||||
|
private readonly ILogger<DiscordSnapshotTask> _log;
|
||||||
|
public DiscordSnapshotTask(DiscordClient discord, EventStore store, ILogger<DiscordSnapshotTask> log) { _discord = discord; _store = store; _log = log; }
|
||||||
|
|
||||||
|
public string Name => "Vanguard: snapshot Discord events";
|
||||||
|
public string Key => "VanguardDiscordSnapshot";
|
||||||
|
public string Description => "Keeps a history of Discord scheduled events so recordings can be matched to them later.";
|
||||||
|
public string Category => "Vanguard";
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.DiscordBotToken) || string.IsNullOrWhiteSpace(cfg.DiscordGuildId)) { _log.LogInformation("Discord not configured; skipping"); return; }
|
||||||
|
var events = await _discord.GetScheduledEventsAsync(cfg.DiscordBotToken, cfg.DiscordGuildId, ct).ConfigureAwait(false);
|
||||||
|
_store.Merge(events);
|
||||||
|
_log.LogInformation("Discord snapshot: {Fresh} current events, {Total} in history", events.Count, _store.Count);
|
||||||
|
progress.Report(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() =>
|
||||||
|
[
|
||||||
|
new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromMinutes(5).Ticks },
|
||||||
|
new TaskTriggerInfo { Type = TaskTriggerInfoType.StartupTrigger },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Jellyfin.Data;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Tasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enforces a direct-play-only policy on every non-administrator user (this box has no hardware encoder).
|
||||||
|
/// Runs at startup and every 30 minutes so users created by the SSO plugin are covered automatically.
|
||||||
|
/// Turn off in the plugin config when the server gets Quick Sync.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlaybackPolicyTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly IUserManager _users;
|
||||||
|
private readonly ILogger<PlaybackPolicyTask> _log;
|
||||||
|
public PlaybackPolicyTask(IUserManager users, ILogger<PlaybackPolicyTask> log) { _users = users; _log = log; }
|
||||||
|
|
||||||
|
public string Name => "Vanguard: enforce playback policy";
|
||||||
|
public string Key => "VanguardPlaybackPolicy";
|
||||||
|
public string Description => "Sets direct-play-only (no video transcoding) on all non-admin users, including new SSO users.";
|
||||||
|
public string Category => "Vanguard";
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
if (!cfg.EnforceDirectPlayOnly) { _log.LogDebug("playback policy enforcement is off"); return; }
|
||||||
|
|
||||||
|
int changed = 0;
|
||||||
|
var all = _users.GetUsers().ToList();
|
||||||
|
foreach (var u in all)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (u.HasPermission(PermissionKind.IsAdministrator) && !cfg.EnforceOnAdminsToo) continue;
|
||||||
|
bool dirty = false;
|
||||||
|
dirty |= Set(u, PermissionKind.EnableVideoPlaybackTranscoding, false);
|
||||||
|
dirty |= Set(u, PermissionKind.EnableAudioPlaybackTranscoding, false);
|
||||||
|
dirty |= Set(u, PermissionKind.EnablePlaybackRemuxing, true); // container remux is cheap and keeps odd clients working
|
||||||
|
if (dirty) { await _users.UpdateUserAsync(u).ConfigureAwait(false); changed++; }
|
||||||
|
}
|
||||||
|
_log.LogInformation("playback policy: {Changed} of {Total} users updated", changed, all.Count);
|
||||||
|
progress.Report(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Set(Jellyfin.Database.Implementations.Entities.User u, PermissionKind kind, bool value)
|
||||||
|
{
|
||||||
|
if (u.HasPermission(kind) == value) return false;
|
||||||
|
u.SetPermission(kind, value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() =>
|
||||||
|
[
|
||||||
|
new TaskTriggerInfo { Type = TaskTriggerInfoType.StartupTrigger },
|
||||||
|
new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromMinutes(30).Ticks },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Providers;
|
||||||
|
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Vanguard.Tasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nightly after the broadcast-day rollover: re-bucket every episode (day -> week -> month -> year),
|
||||||
|
/// rename seasons, and refresh series so virtual seasons appear/disappear. Paths never change, so
|
||||||
|
/// item ids and watched state are kept.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SeasonRollTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _library;
|
||||||
|
private readonly IFileSystem _fs;
|
||||||
|
private readonly ILogger<SeasonRollTask> _log;
|
||||||
|
public SeasonRollTask(ILibraryManager library, IFileSystem fs, ILogger<SeasonRollTask> log) { _library = library; _fs = fs; _log = log; }
|
||||||
|
|
||||||
|
public string Name => "Vanguard: roll seasons";
|
||||||
|
public string Key => "VanguardSeasonRoll";
|
||||||
|
public string Description => "Moves recordings between day/week/month/year seasons as time passes and fixes season names.";
|
||||||
|
public string Category => "Vanguard";
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
var tz = VanguardEpisodeProvider.Tz(cfg);
|
||||||
|
var overrides = SeasonPolicy.ParseOverrides(cfg.SeasonNameOverrides);
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var episodes = _library.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Episode], Recursive = true, IsVirtualItem = false });
|
||||||
|
var touchedSeries = new HashSet<Guid>();
|
||||||
|
int moved = 0, i = 0;
|
||||||
|
foreach (var item in episodes.OfType<Episode>())
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
progress.Report(80.0 * ++i / Math.Max(1, episodes.Count));
|
||||||
|
if (string.IsNullOrEmpty(item.Path)) continue;
|
||||||
|
var when = ItemTimestamp.Resolve(item.Path, tz);
|
||||||
|
var slot = SeasonPolicy.Resolve(when.Utc, now, tz, cfg.BroadcastDayRolloverHour, overrides);
|
||||||
|
if (item.ParentIndexNumber == slot.SeasonNumber && item.IndexNumber == slot.EpisodeNumber) continue;
|
||||||
|
item.ParentIndexNumber = slot.SeasonNumber;
|
||||||
|
item.IndexNumber = slot.EpisodeNumber;
|
||||||
|
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, ct).ConfigureAwait(false);
|
||||||
|
if (item.SeriesId != Guid.Empty) touchedSeries.Add(item.SeriesId);
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// let each touched series re-evaluate its (virtual) seasons, then name every season from its number
|
||||||
|
var options = new MetadataRefreshOptions(new DirectoryService(_fs)) { MetadataRefreshMode = MetadataRefreshMode.Default, ImageRefreshMode = MetadataRefreshMode.None };
|
||||||
|
foreach (var sid in touchedSeries)
|
||||||
|
{
|
||||||
|
if (_library.GetItemById(sid) is Series s) await s.RefreshMetadata(options, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
var seasons = _library.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Season], Recursive = true });
|
||||||
|
int renamed = 0;
|
||||||
|
foreach (var s in seasons.OfType<Season>())
|
||||||
|
{
|
||||||
|
if (!s.IndexNumber.HasValue) continue;
|
||||||
|
var d = SeasonPolicy.Describe(s.IndexNumber.Value, overrides);
|
||||||
|
if (d is null || string.Equals(s.Name, d.Value.Name, StringComparison.Ordinal)) continue;
|
||||||
|
s.Name = d.Value.Name;
|
||||||
|
await s.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, ct).ConfigureAwait(false);
|
||||||
|
renamed++;
|
||||||
|
}
|
||||||
|
_log.LogInformation("Season roll: {Moved} episodes re-bucketed, {Renamed} seasons renamed, {Series} series refreshed", moved, renamed, touchedSeries.Count);
|
||||||
|
progress.Report(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
var hour = Math.Clamp(cfg.BroadcastDayRolloverHour, 0, 23);
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new TaskTriggerInfo { Type = TaskTriggerInfoType.DailyTrigger, TimeOfDayTicks = TimeSpan.FromHours(hour).Add(TimeSpan.FromMinutes(10)).Ticks },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
60
jellyfin-plugin-vanguard/README.md
Normal file
60
jellyfin-plugin-vanguard/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Vanguard Metadata — Jellyfin plugin
|
||||||
|
|
||||||
|
Metadata provider for gameplay recordings. Each streamer/submitter folder is a show; season and
|
||||||
|
episode numbers come from the recording timestamp under a rolling policy; names, avatars and episode
|
||||||
|
titles come from Discord via Authentik's identity link; new recordings and clips are announced in a
|
||||||
|
Discord channel.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
- `Jellyfin.Plugin.Vanguard.Core/` — pure logic: `SeasonPolicy` (rolling buckets, naming, overrides),
|
||||||
|
`ItemTimestamp` (sidecar → filename patterns → mtime). No Jellyfin dependency. Unit-tested.
|
||||||
|
- `Jellyfin.Plugin.Vanguard/` — the plugin: providers (Series, Series images, Season, Episode),
|
||||||
|
services (Discord REST, Authentik REST, event store, identity resolver, feed), scheduled tasks
|
||||||
|
(Discord snapshot every 5 min; season roll nightly after the rollover hour), config page.
|
||||||
|
- `Jellyfin.Plugin.Vanguard.Tests/` — xunit.
|
||||||
|
|
||||||
|
## Season scheme
|
||||||
|
| age of item | season # | season name |
|
||||||
|
|---|---|---|
|
||||||
|
| current ISO week | `YYYYMMDD` | "Thursday, Oct 29" |
|
||||||
|
| current month | `1YYYYWW` | "Week 44" |
|
||||||
|
| current year | `YYYYMM` | "October 2026" |
|
||||||
|
| older | `YYYY` | "2026" |
|
||||||
|
|
||||||
|
Episode # = `MMDDHHMMSS` of the local timestamp — never changes when the season rolls.
|
||||||
|
Broadcast day rolls at the configured hour (default 04:00). Overrides: `seasonNumber=Name`, one per line.
|
||||||
|
|
||||||
|
## Timestamp sources, in order
|
||||||
|
1. `<name>.json` / `<name>.clip.json` sidecar with `"timestamp"` (UTC ISO-8601) — written by the clip bot
|
||||||
|
2. filename: MediaMTX `2026-10-29_21-03-11`, Game Bar `2026-10-29 21-03-11`,
|
||||||
|
ShadowPlay `2026.10.29 - 21.03.11`, Steam `20261029_210311`
|
||||||
|
3. file modification time (Syncthing preserves the gamer's mtime)
|
||||||
|
|
||||||
|
## Build / package
|
||||||
|
./package.sh # tests + Release build → dist/VanguardMetadata_<ver>.zip + manifest.json
|
||||||
|
Requires .NET 10 SDK. Bump `version` in `build.yaml` for a release.
|
||||||
|
|
||||||
|
## Install on the server (manual, v0.1)
|
||||||
|
scp dist/VanguardMetadata_0.1.0.0.zip debian@HOST:/tmp/
|
||||||
|
ssh debian@HOST 'sudo -u "#1000" mkdir -p "/srv/appdata/jellyfin/config/plugins/Vanguard Metadata_0.1.0.0" && sudo -u "#1000" python3 -m zipfile -e /tmp/VanguardMetadata_0.1.0.0.zip "/srv/appdata/jellyfin/config/plugins/Vanguard Metadata_0.1.0.0/" && cd ~/vanguard-stream && docker compose restart jellyfin'
|
||||||
|
Later: serve `dist/` at `https://HOST/plugins/` (Caddy) and add it as a repository in Jellyfin so updates
|
||||||
|
come through the Catalog.
|
||||||
|
|
||||||
|
## Runs with an empty config
|
||||||
|
Seasons, episode numbers, timestamps and the nightly roll need nothing external. Without a Discord bot
|
||||||
|
token the show is named after its folder, has no poster, and episode titles are the dock title; without
|
||||||
|
Authentik no folder→Discord mapping is attempted; without a feed channel nothing is posted. Add the
|
||||||
|
credentials later and re-run "roll seasons" / refresh the libraries to pick up names and avatars.
|
||||||
|
|
||||||
|
## Configure (Dashboard → Plugins → Vanguard Metadata)
|
||||||
|
- Discord: bot token (add a **Bot** to the existing Authentik Discord application; enable the Server
|
||||||
|
Members intent; invite it to the server), guild ID, feed channel ID, public Jellyfin URL.
|
||||||
|
- Authentik: URL + API token of a read-only service account (used to map folder → Discord user).
|
||||||
|
- Folder → identity overrides if a streamer's folder name isn't their Authentik username.
|
||||||
|
- Libraries (Dashboard → Libraries → each of streams/clips): content type Shows; metadata downloaders:
|
||||||
|
**only** "Vanguard Metadata" for Series, Season and Episode; image fetchers: Vanguard Metadata (Series)
|
||||||
|
and **Screen Grabber** (Episode) — the grab is what the Discord embed attaches.
|
||||||
|
|
||||||
|
## Tasks (Dashboard → Scheduled Tasks → Vanguard)
|
||||||
|
- "snapshot Discord events": every 5 min + startup. Run it once after configuring.
|
||||||
|
- "roll seasons": daily at rollover+10 min. Run it once after the first library scan.
|
||||||
18
jellyfin-plugin-vanguard/build.yaml
Normal file
18
jellyfin-plugin-vanguard/build.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: "Vanguard Metadata"
|
||||||
|
guid: "d00ebd97-f987-4678-8089-c8f0f8a6ab3e"
|
||||||
|
version: "0.1.1.0"
|
||||||
|
targetAbi: "12.4.0.0"
|
||||||
|
framework: "net10.0"
|
||||||
|
overview: "Season/episode metadata for gameplay recordings from timestamps, Discord and Authentik."
|
||||||
|
description: >
|
||||||
|
Treats each streamer/submitter folder as a show. Season and episode numbers come from the
|
||||||
|
recording timestamp under a rolling policy (weekday -> week -> month -> year). Names, avatars and
|
||||||
|
episode titles come from Discord (members, scheduled events) via Authentik's identity link.
|
||||||
|
category: "Metadata"
|
||||||
|
owner: "techy"
|
||||||
|
artifacts:
|
||||||
|
- "Jellyfin.Plugin.Vanguard.dll"
|
||||||
|
- "Jellyfin.Plugin.Vanguard.Core.dll"
|
||||||
|
changelog: >
|
||||||
|
0.1.1.0 playback-policy enforcement task (direct-play-only for all users)
|
||||||
|
0.1.0.0 initial skeleton
|
||||||
65
jellyfin-plugin-vanguard/package.sh
Executable file
65
jellyfin-plugin-vanguard/package.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build Release and produce dist/VanguardMetadata_<version>.zip (DLLs + meta.json) ready to drop into
|
||||||
|
# Jellyfin's plugins folder, plus dist/manifest.json for serving as a plugin repository.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
command -v yq >/dev/null 2>&1 || true
|
||||||
|
get() { grep -E "^$1:" build.yaml | head -1 | sed -E 's/^[a-zA-Z]+: *"?([^"]*)"?$/\1/'; }
|
||||||
|
NAME=$(get name); GUID=$(get guid); VERSION=$(get version); ABI=$(get targetAbi); OVERVIEW=$(get overview); OWNER=$(get owner); CATEGORY=$(get category)
|
||||||
|
DESC=$(python3 -c "import yaml,sys; print(yaml.safe_load(open('build.yaml'))['description'].strip())")
|
||||||
|
CHANGELOG=$(python3 -c "import yaml,sys; print(yaml.safe_load(open('build.yaml'))['changelog'].strip())")
|
||||||
|
|
||||||
|
dotnet test Jellyfin.Plugin.Vanguard.Tests --nologo -v q
|
||||||
|
dotnet build Jellyfin.Plugin.Vanguard -c Release --nologo -v q
|
||||||
|
|
||||||
|
OUT=Jellyfin.Plugin.Vanguard/bin/Release/net10.0
|
||||||
|
STAGE=dist/stage; rm -rf dist; mkdir -p "$STAGE"
|
||||||
|
cp "$OUT/Jellyfin.Plugin.Vanguard.dll" "$OUT/Jellyfin.Plugin.Vanguard.Core.dll" "$STAGE/"
|
||||||
|
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
cat > "$STAGE/meta.json" <<JSON
|
||||||
|
{
|
||||||
|
"category": "$CATEGORY",
|
||||||
|
"changelog": $(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$CHANGELOG"),
|
||||||
|
"description": $(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$DESC"),
|
||||||
|
"guid": "$GUID",
|
||||||
|
"name": "$NAME",
|
||||||
|
"overview": "$OVERVIEW",
|
||||||
|
"owner": "$OWNER",
|
||||||
|
"targetAbi": "$ABI",
|
||||||
|
"timestamp": "$TS",
|
||||||
|
"version": "$VERSION",
|
||||||
|
"status": "Active",
|
||||||
|
"autoUpdate": true,
|
||||||
|
"imagePath": ""
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
ZIP="dist/VanguardMetadata_${VERSION}.zip"
|
||||||
|
( cd "$STAGE" && zip -q -r "../../$ZIP" . )
|
||||||
|
SUM=$(md5sum "$ZIP" | cut -d' ' -f1)
|
||||||
|
# repository manifest (serve dist/ at https://HOST/plugins/ and add that URL + manifest.json in Jellyfin)
|
||||||
|
cat > dist/manifest.json <<JSON
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"guid": "$GUID",
|
||||||
|
"name": "$NAME",
|
||||||
|
"description": $(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$DESC"),
|
||||||
|
"overview": "$OVERVIEW",
|
||||||
|
"owner": "$OWNER",
|
||||||
|
"category": "$CATEGORY",
|
||||||
|
"imageUrl": "",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "$VERSION",
|
||||||
|
"changelog": $(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$CHANGELOG"),
|
||||||
|
"targetAbi": "$ABI",
|
||||||
|
"sourceUrl": "https://PLUGIN_HOST/plugins/VanguardMetadata_${VERSION}.zip",
|
||||||
|
"checksum": "$SUM",
|
||||||
|
"timestamp": "$TS"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
echo "built: $ZIP (md5 $SUM)"
|
||||||
|
echo "install (manual): python3 -m zipfile -e $ZIP \"/srv/appdata/jellyfin/config/plugins/${NAME}_${VERSION}/\" (as uid 1000; Debian has no unzip) then restart jellyfin"
|
||||||
5
jellyfin/branding-disclaimer.html
Normal file
5
jellyfin/branding-disclaimer.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<form action="https://watch.HOST/sso/OID/start/authentik">
|
||||||
|
<button class="raised block emby-button button-submit">Sign in with Discord</button>
|
||||||
|
</form>
|
||||||
|
<input type="checkbox" id="showAdmin">
|
||||||
|
<label for="showAdmin" class="showAdminLabel">Admin login</label>
|
||||||
14
jellyfin/branding.css
Normal file
14
jellyfin/branding.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* Jellyfin -> Dashboard -> General -> Branding -> Custom CSS
|
||||||
|
Login page shows only the Discord (Authentik SSO) button; "Admin login" reveals the local form. */
|
||||||
|
.disclaimerContainer { display: block; }
|
||||||
|
a.raised.emby-button { padding: .9em 1em; color: inherit !important; }
|
||||||
|
|
||||||
|
#loginPage .manualLoginForm,
|
||||||
|
#loginPage .visualLoginForm,
|
||||||
|
#loginPage .btnForgotPassword { display: none !important; }
|
||||||
|
|
||||||
|
#showAdmin { display: none; }
|
||||||
|
.showAdminLabel { display: block; text-align: center; margin-top: 1.2em; color: #999; cursor: pointer; text-decoration: underline; font-size: .9em; }
|
||||||
|
|
||||||
|
#loginPage:has(#showAdmin:checked) .manualLoginForm { display: block !important; }
|
||||||
|
#loginPage:has(#showAdmin:checked) .showAdminLabel { display: none; }
|
||||||
12
mariadb/init/10-gitea.sh
Executable file
12
mariadb/init/10-gitea.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Runs once, on MariaDB's first initialisation (docker-entrypoint-initdb.d). Creates Gitea's database and user.
|
||||||
|
# Already-initialised server? run ./createGiteaDb.sh from the bundle root instead.
|
||||||
|
set -e
|
||||||
|
: "${GITEA_DB_PASSWORD:?GITEA_DB_PASSWORD not set}"
|
||||||
|
mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" <<SQL
|
||||||
|
CREATE DATABASE IF NOT EXISTS gitea CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER IF NOT EXISTS 'gitea'@'%' IDENTIFIED BY '${GITEA_DB_PASSWORD}';
|
||||||
|
GRANT ALL PRIVILEGES ON gitea.* TO 'gitea'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
SQL
|
||||||
|
echo "gitea database + user created"
|
||||||
45
mediamtx.yml
Normal file
45
mediamtx.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# MediaMTX — ingest only. No playback endpoints exposed.
|
||||||
|
logLevel: info
|
||||||
|
|
||||||
|
rtsp: no
|
||||||
|
webrtc: no
|
||||||
|
hls: no
|
||||||
|
api: no
|
||||||
|
metrics: no
|
||||||
|
pprof: no
|
||||||
|
|
||||||
|
rtmp: yes
|
||||||
|
rtmpAddress: :1935
|
||||||
|
|
||||||
|
srt: yes
|
||||||
|
srtAddress: :8890
|
||||||
|
|
||||||
|
# ---- auth -------------------------------------------------------------
|
||||||
|
# Each streamer may publish only under their own prefix.
|
||||||
|
# RTMP key: techy/Title?user=techy&pass=CHANGEME
|
||||||
|
# SRT: srt://HOST:8890?streamid=publish:techy/Title:techy:CHANGEME
|
||||||
|
authMethod: internal
|
||||||
|
authInternalUsers:
|
||||||
|
- user: techy
|
||||||
|
pass: CHANGEME_TECHY
|
||||||
|
permissions:
|
||||||
|
- action: publish
|
||||||
|
path: ~^techy/[A-Za-z0-9_.-]+$
|
||||||
|
- user: diceofire
|
||||||
|
pass: CHANGEME_DICE
|
||||||
|
permissions:
|
||||||
|
- action: publish
|
||||||
|
path: ~^diceofire/[A-Za-z0-9_.-]+$
|
||||||
|
|
||||||
|
# ---- recording --------------------------------------------------------
|
||||||
|
pathDefaults:
|
||||||
|
record: yes
|
||||||
|
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S
|
||||||
|
recordFormat: fmp4
|
||||||
|
recordPartDuration: 1s
|
||||||
|
recordSegmentDuration: 12h # one file per stream unless it runs >12h
|
||||||
|
recordDeleteAfter: 0s # never auto-delete; the archive job handles it
|
||||||
|
|
||||||
|
paths:
|
||||||
|
# any name under a streamer's prefix; inherits pathDefaults
|
||||||
|
~^(techy|diceofire)/[A-Za-z0-9_.-]+$: {}
|
||||||
13
registerGiteaRunner.sh
Executable file
13
registerGiteaRunner.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Generate a runner registration token from Gitea, store it in .env, (re)start the runner.
|
||||||
|
# Re-run any time the runner shows as offline/unregistered.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
docker ps --format '{{.Names}}' | grep -qx gitea || { echo "gitea container not running"; exit 1; }
|
||||||
|
TOKEN=$(docker exec -u 1000 gitea gitea --config /data/gitea/conf/app.ini actions generate-runner-token | tail -1 | tr -d '\r')
|
||||||
|
[[ ${#TOKEN} -ge 20 ]] || { echo "could not get a token: $TOKEN"; exit 1; }
|
||||||
|
if grep -q '^GITEA_RUNNER_TOKEN=' .env; then sed -i "s|^GITEA_RUNNER_TOKEN=.*|GITEA_RUNNER_TOKEN=$TOKEN|" .env; else echo "GITEA_RUNNER_TOKEN=$TOKEN" >> .env; fi
|
||||||
|
sudo rm -rf /srv/appdata/act_runner/.runner 2>/dev/null || true # forget any previous registration
|
||||||
|
docker compose up -d --force-recreate act_runner
|
||||||
|
sleep 6; docker logs act_runner --tail 8 2>&1 | sed 's/^/ /'
|
||||||
|
echo "check: Gitea -> Site Administration -> Actions -> Runners (should list 'vanguard-runner' as idle)"
|
||||||
14
syncthing/stignore
Normal file
14
syncthing/stignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Server-side ignore list for a clips receive folder (clips/<user>): pull video only.
|
||||||
|
// .stlocal is appended to by the sweep: raws it converted (then deleted locally) and the standard
|
||||||
|
// files it produced. Ignored = not tracked, not flagged, never re-pulled, never sent back.
|
||||||
|
#include .stlocal
|
||||||
|
// Ignored files are never downloaded from the sender. Subfolders are still traversed
|
||||||
|
// because include (!) patterns are present. (?i) = case-insensitive.
|
||||||
|
(?i)!*.mp4
|
||||||
|
(?i)!*.mkv
|
||||||
|
(?i)!*.mov
|
||||||
|
(?i)!*.m4v
|
||||||
|
(?i)!*.webm
|
||||||
|
(?i)!*.ts
|
||||||
|
// everything else is ignored
|
||||||
|
*
|
||||||
15
systemd/dumpd.service
Normal file
15
systemd/dumpd.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
RequiresMountsFor=/srv/media
|
||||||
|
Description=Dump-tonight endpoint (127.0.0.1:8080)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/bin/dumpd.py
|
||||||
|
Environment=DUMP_USERS=/etc/vanguard/dump-users.txt
|
||||||
|
Environment=DUMP_TZ=America/New_York
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
10
systemd/normalize-sweep.service
Normal file
10
systemd/normalize-sweep.service
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Normalize finished recordings/clips to the editing standard
|
||||||
|
RequiresMountsFor=/srv/media
|
||||||
|
After=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/normalize-sweep.sh
|
||||||
|
Nice=15
|
||||||
|
IOSchedulingClass=idle
|
||||||
10
systemd/normalize-sweep.timer
Normal file
10
systemd/normalize-sweep.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run normalize sweep every 5 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
AccuracySec=30s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
7
systemd/trash-purge.service
Normal file
7
systemd/trash-purge.service
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[Unit]
|
||||||
|
RequiresMountsFor=/srv/media
|
||||||
|
Description=Purge dumped nights older than 3 days
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/trash-purge.sh
|
||||||
9
systemd/trash-purge.timer
Normal file
9
systemd/trash-purge.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily trash purge
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 05:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Reference in New Issue
Block a user