Self-service clip intake (/clips + dumpd /me/clips via Syncthing REST) and role-aware gateway page (/about + dumpd /me)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
16
README.md
16
README.md
@@ -116,9 +116,14 @@ its token expires, but a reconnect needs a fresh one.
|
||||
|
||||
Browser streaming without OBS: the live portal's **Go Live** (Windows Chrome 141+ for window audio + GPU encode).
|
||||
|
||||
## 5a. Onboarding a gamer's clips (Syncthing)
|
||||
## 5a. Onboarding a gamer's clips (Syncthing) — self-service
|
||||
|
||||
Server side (once per person; name = their Authentik/Discord username, lowercase):
|
||||
Gamers do it themselves at **https://HOST/clips/** (Discord login): the page shows our Syncthing Device ID and
|
||||
asks for theirs; on submit, dumpd creates `clips/<username>` with the video-only `.stignore`, adds their device
|
||||
to our Syncthing (REST API, key read from `/srv/media/.syncthing/config.xml`) and creates the receive-only
|
||||
folder `clips-<username>` shared with them (min free 300 GB). They accept the device + folder on their PC,
|
||||
point it at their clip directory, Send Only. The page shows pairing/connection/sync state. Idempotent.
|
||||
Manual fallback (admin, same result for the server folder):
|
||||
|
||||
./addClipUser.sh <username> # creates /srv/media/clips/<username> with the video-only .stignore
|
||||
|
||||
@@ -132,6 +137,13 @@ offer pops up: **Add** → Folder Path `/var/syncthing/clips/<username>`, Folder
|
||||
Minimum Free Disk Space **300 GB**, Save. Clips start arriving; the sweep normalizes them; Jellyfin shows the
|
||||
folder as that person's show. Their own folder structure is mirrored; only video files are pulled.
|
||||
|
||||
## 5a². Gateway page — https://HOST/about/
|
||||
|
||||
Role-aware landing page (Discord login): everyone sees Watch / Live / Send clips / Sign out with short how-tos;
|
||||
`streamers` get the dock + OBS setup steps; `editors` the browser + multicam/timecode notes; `media-admins`
|
||||
trash/Syncthing/whoami; `git-users` Gitea; `bot-users` the bot; `admins`/`authentik Admins` the admin consoles and
|
||||
where things live. Identity comes from `GET /me` (dumpd). Point people here first.
|
||||
|
||||
## 5b. Syncthing hardening (do once, in the server's Syncthing UI)
|
||||
|
||||
Syncthing receives straight into `clips/<user>` (Syncthing UI path `/var/syncthing/clips/<user>`),
|
||||
|
||||
@@ -27,7 +27,8 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
||||
|---|---|---|---|
|
||||
| Streamer | One-time: dock → Timecode band → "Add band + clock source" (OBS) or leave the Go Live checkbox on — gives editors frame-accurate timecode | OBS dock / Go Live | live |
|
||||
| Streamer | Type a title (live preview + naming rules), Go Live, Stop. "Dump tonight" if it was junk (3-day undo). Manual-setup accordion with endpoints and a hidden token | OBS browser dock `https://ovh1…/dock/` | Discord login in the dock; dock mints a 12 h token per Go Live — **no passwords, verified live** |
|
||||
| Gamer | Nothing; clips save where they always did | their clipper + Syncthing, paired once | — |
|
||||
| Gamer | Self-serve pairing at `/clips/` (paste Syncthing Device ID → folder + share created); then nothing, clips save where they always did | `https://ovh1…/clips/` + Syncthing | Discord — **pending deploy** |
|
||||
| Everyone | Landing page with role-aware links and instructions | `https://ovh1…/about/` | Discord — **pending deploy** |
|
||||
| Editor | Multicam: drop clips on a timeline, sync by the embedded timecode track (frame-accurate when the band was on), cross-check with voice audio; export at the game resolution to drop the band | Resolve / Premiere | — |
|
||||
| Editor | Browse by streamer/title, scrub in browser, download originals (zip/tar for folders), upload finals | copyparty `https://ovh1…/` | Discord (Authentik); rights by group `editors` / `media-admins` |
|
||||
| Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) |
|
||||
@@ -36,7 +37,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
||||
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | Discord (Authentik), group `git-users`; `git-admins` = site admin |
|
||||
| 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 | Hostnames, bot token/guild, webhook; streamers/editors are Discord roles → Authentik groups | `./configure.py` | ssh |
|
||||
| Admin | Pair a gamer's device, approve their folder | Syncthing UI `https://ovh1…/sync/` | Discord + group `media-admins` (Syncthing's own login disabled) |
|
||||
| Admin | Watch pairings / folder states (gamers self-serve the setup) | Syncthing UI `https://ovh1…/sync/` | Discord + group `media-admins` (Syncthing's own login disabled) |
|
||||
| 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 |
|
||||
@@ -47,7 +48,8 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
||||
|---|---|---|
|
||||
| MediaMTX | RTMP/SRT ingest, records fMP4; publish auth delegated to dumpd (tokens via `/mtx-auth`) | live, token auth verified |
|
||||
| copyparty | browse/scrub/download/finals upload | behind Authentik, group permissions (`editors`, `media-admins`), Discord-only, no WebDAV | live |
|
||||
| dumpd + dock | dump, stream tokens, MediaMTX auth hook, `/time`; OBS dock with timecode-band setup | identity from Authentik headers | live |
|
||||
| dumpd + dock | dump, stream tokens, MediaMTX auth hook, `/time`, `/me` identity, `/me/clips` Syncthing self-service; OBS dock with timecode-band setup | identity from Authentik headers | live; `/me`, `/me/clips` **pending deploy** |
|
||||
| `/about/` gateway, `/clips/` self-service | static pages behind the gate | — | **built, pending deploy** |
|
||||
| Timecode | `bin/vgstrip.py` decoder, `tmcd` + sidecar in the sweep, `/dock/clock/` strip page, Go Live band composite, portal crop | server clock is the reference | **live, verified** (OBS: frame-accurate, 0 gaps) |
|
||||
| Syncthing | clip intake, UI at `/sync/` | UI behind Authentik + `media-admins`, own GUI auth off | live |
|
||||
| normalize sweep | editing standard, one copy per clip | live |
|
||||
|
||||
169
about/index.html
Normal file
169
about/index.html
Normal file
@@ -0,0 +1,169 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Vanguard Media</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root{--bg:#141414;--fg:#e6e6e6;--mut:#9a9a9a;--acc:#3d8bfd;--ok:#2ecc71;--box:#1f1f1f;--line:#2e2e2e}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif}
|
||||
main{max-width:860px;margin:0 auto;padding:28px 16px 48px}
|
||||
header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
|
||||
h1{font-size:26px;margin:0}.who{color:var(--mut)}.who b{color:var(--fg)}
|
||||
.pills{margin:6px 0 22px}.pill{display:inline-block;background:#2a2a2a;border-radius:999px;padding:2px 10px;font-size:12px;margin:0 6px 6px 0;color:#bbb}
|
||||
section{background:var(--box);border:1px solid var(--line);border-radius:14px;padding:18px 20px;margin-bottom:16px}
|
||||
section h2{margin:0 0 4px;font-size:17px}section .lead{color:var(--mut);margin:0 0 12px}
|
||||
.links{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;margin:10px 0}
|
||||
a.card{display:block;background:#262626;border:1px solid #333;border-radius:10px;padding:12px 14px;color:var(--fg);text-decoration:none}
|
||||
a.card:hover{border-color:var(--acc)}a.card b{display:block;margin-bottom:2px}a.card span{color:var(--mut);font-size:13px}
|
||||
details{margin-top:8px}summary{cursor:pointer;color:var(--mut);font-size:13px}
|
||||
ol,ul{margin:8px 0 0;padding-left:20px}li{margin:4px 0}code{font-family:ui-monospace,monospace;background:#121212;padding:1px 6px;border-radius:5px}
|
||||
.hidden{display:none}footer{color:var(--mut);font-size:13px;margin-top:20px}footer a{color:var(--mut)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header><h1>Vanguard Media</h1><div class="who">signed in as <b id="who">…</b></div></header>
|
||||
<div class="pills" id="pills"></div>
|
||||
|
||||
<section id="s-everyone">
|
||||
<h2>Watch</h2>
|
||||
<p class="lead">Everything here uses your Discord login. One sign-in covers all of it.</p>
|
||||
<div class="links">
|
||||
<a class="card" id="l-watch" href="#"><b>Recordings & clips</b><span>Jellyfin — by streamer, by day/week/month</span></a>
|
||||
<a class="card" id="l-live" href="#"><b>Live now</b><span>every active stream in one page, audio follows the window you click</span></a>
|
||||
<a class="card" href="/clips/"><b>Send your game clips</b><span>pair your PC once; clips upload themselves</span></a>
|
||||
<a class="card" href="/logout"><b>Sign out</b><span>ends the Discord session for all Vanguard sites</span></a>
|
||||
</div>
|
||||
<details><summary>How watching works</summary>
|
||||
<ul>
|
||||
<li>Jellyfin: each streamer is a show. This week's recordings sit under weekday seasons, older ones roll into week, month and year. Nothing moves; it is only re-labelled.</li>
|
||||
<li>Live: windows open as people go live and close when they stop. Click a window to hear it; the speaker button pins audio on; the slider is per window.</li>
|
||||
<li>Clips: install Syncthing on your gaming PC, paste its Device ID on the clips page, accept the folder it offers. Only video files are pulled.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="s-streamers" class="hidden">
|
||||
<h2>Stream</h2>
|
||||
<p class="lead">You are in <code>streamers</code>: you can publish under your own name. No passwords, no stream keys to keep.</p>
|
||||
<div class="links">
|
||||
<a class="card" href="/dock/"><b>OBS dock</b><span>title → Go Live; Dump tonight; manual setup</span></a>
|
||||
<a class="card" id="l-golive" href="#"><b>Go Live from the browser</b><span>Windows Chrome: share the game window, no OBS</span></a>
|
||||
</div>
|
||||
<details><summary>OBS setup (once)</summary>
|
||||
<ol>
|
||||
<li>OBS → Tools → WebSocket Server Settings → enable, set a password.</li>
|
||||
<li>OBS → Docks → Custom Browser Docks → add <code id="dockurl">…/dock/</code>; log in with Discord inside the dock.</li>
|
||||
<li>Dock → Settings: OBS WebSocket password (the rest is prefilled). Optional title prefix/suffix (<code>{date}</code> = today).</li>
|
||||
<li>Dock → Timecode band → <b>Add band + clock source</b> while not streaming (gives editors a frame-accurate timecode).</li>
|
||||
<li>Per stream: type a title, <b>Go Live</b>. The dock mints a 12-hour token and sets your stream key itself.</li>
|
||||
</ol>
|
||||
</details>
|
||||
<details><summary>Rules worth knowing</summary>
|
||||
<ul>
|
||||
<li>Everything streamed is recorded and visible to editors and members. <b>Dump tonight</b> throws a night away (3-day undo by an admin).</li>
|
||||
<li>Titles: letters, digits, <code>_ . -</code>; no double underscore; the dock shows the final name as you type.</li>
|
||||
<li>Browser Go Live needs Windows + Chrome/Edge 141+ for window audio and the GPU encoder; on Linux use OBS.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="s-editors" class="hidden">
|
||||
<h2>Edit</h2>
|
||||
<p class="lead">You are in <code>editors</code>: read streams and clips, write finals.</p>
|
||||
<div class="links">
|
||||
<a class="card" href="/"><b>Browse & download</b><span>copyparty — scrub in the browser, download originals, zip folders</span></a>
|
||||
<a class="card" href="/finals/"><b>Upload finals</b><span>the only folder editors can write to</span></a>
|
||||
</div>
|
||||
<details><summary>Multicam & timecode</summary>
|
||||
<ul>
|
||||
<li>Every stream file has a timecode track (time of day, Eastern) and a <code>.json</code> sidecar with the exact capture time, source (<code>strip</code> = frame-accurate from the overscan band, <code>arrival</code> = coarse), drift and gaps.</li>
|
||||
<li>Multicam: sync by timecode, cross-check with voice audio. Export at the game resolution (2560×1440 / 1920×1080) — that drops the thin band at the bottom.</li>
|
||||
<li>Files are H.264 MP4, constant frame rate, index up front. Streams are published a few minutes after they end.</li>
|
||||
<li>No WebDAV (Discord-only login). Select files/folders and use copyparty's zip/tar download.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="s-mediaadmins" class="hidden">
|
||||
<h2>Media admin</h2>
|
||||
<p class="lead">You are in <code>media-admins</code>: full rights on every folder, trash, Syncthing, dump anyone's night.</p>
|
||||
<div class="links">
|
||||
<a class="card" href="/trash/"><b>Trash</b><span>dumped nights, purged after 3 days</span></a>
|
||||
<a class="card" href="/sync/"><b>Syncthing</b><span>device pairing, folder states</span></a>
|
||||
<a class="card" href="/whoami"><b>Who am I</b><span>identity headers as the apps see them</span></a>
|
||||
</div>
|
||||
<details><summary>Admin notes</summary>
|
||||
<ul>
|
||||
<li>Group changes in Authentik apply on the next login: <a href="/logout">sign out</a> and back in.</li>
|
||||
<li>Dump anyone's night: the dock's Dump works on your own; for others use the API with <code>{"user":"name"}</code> or ask the owner.</li>
|
||||
<li>New gamer: they self-serve on <a href="/clips/">/clips/</a>; you only need to watch Syncthing accept the device.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="s-git" class="hidden">
|
||||
<h2>Code</h2>
|
||||
<p class="lead">You are in <code>git-users</code>.</p>
|
||||
<div class="links">
|
||||
<a class="card" id="l-git" href="#"><b>Gitea</b><span>repos, issues, CI — sign in with authentik</span></a>
|
||||
</div>
|
||||
<details><summary>Notes</summary>
|
||||
<ul><li>SSH remotes use port 2222: <code id="gitssh">ssh://git@…:2222/org/repo.git</code>. Add your key under Settings → SSH keys.</li>
|
||||
<li>The stack's own repo is <code>techy/Media-Server-Setup</code>.</li></ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="s-bot" class="hidden">
|
||||
<h2>Bot</h2>
|
||||
<p class="lead">You are in <code>bot-users</code>.</p>
|
||||
<div class="links"><a class="card" id="l-bot" href="#"><b>Bot web UI</b><span>Discord bot control panel</span></a></div>
|
||||
</section>
|
||||
|
||||
<section id="s-admins" class="hidden">
|
||||
<h2>Administration</h2>
|
||||
<p class="lead">You are in <code>admins</code> or <code>authentik Admins</code>.</p>
|
||||
<div class="links">
|
||||
<a class="card" id="l-auth" href="#"><b>Authentik admin</b><span>users, groups, Discord source, policies</span></a>
|
||||
<a class="card" id="l-jfadmin" href="#"><b>Jellyfin dashboard</b><span>libraries, plugin, scheduled tasks</span></a>
|
||||
<a class="card" id="l-gitadmin" href="#"><b>Gitea site admin</b><span>auth sources, runners</span></a>
|
||||
</div>
|
||||
<details><summary>Where things live</summary>
|
||||
<ul>
|
||||
<li>Server: <code>~/vanguard-stream</code> on the box; <code>STATUS.md</code> = what runs / who does what / what's unfinished; <code>README.md</code> = how.</li>
|
||||
<li>Change hostnames, bot token: <code>./configure.py</code>. Plugin updates: <code>./installJellyfinPlugin.sh</code>. Portal: <code>./deployLive.sh</code> (dev box).</li>
|
||||
<li>Streamers = Authentik group <code>streamers</code>; editors = <code>editors</code>; everything follows Discord roles via the roles policy.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<footer>Vanguard Media · <a href="/about/">/about</a> · <a href="/logout">sign out</a></footer>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const host = location.hostname, base = host.replace(/^[^.]+\./, '');
|
||||
const sub = s => `https://${s}.${base}`;
|
||||
$('l-watch').href = sub('watch'); $('l-live').href = sub('live'); $('l-golive').href = sub('live') + '/#golive';
|
||||
$('l-git').href = sub('git'); $('l-bot').href = sub('bot'); $('l-auth').href = sub('auth') + '/if/admin/';
|
||||
$('l-jfadmin').href = sub('watch') + '/web/#/dashboard'; $('l-gitadmin').href = sub('git') + '/-/admin';
|
||||
$('dockurl').textContent = location.origin + '/dock/'; $('gitssh').textContent = `ssh://git@${'git.' + base}:2222/org/repo.git`;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/me', {credentials:'same-origin'}); const d = await r.json(); if (!r.ok) throw new Error(d.error || r.status);
|
||||
$('who').textContent = d.name && d.name !== d.user ? `${d.name} (${d.user})` : d.user;
|
||||
$('pills').innerHTML = d.groups.map(g => `<span class="pill">${g}</span>`).join('') || '<span class="pill">no groups yet — ask an admin for a role</span>';
|
||||
const has = g => d.groups.includes(g);
|
||||
const show = (id, on) => $(id).classList.toggle('hidden', !on);
|
||||
const isAdmin = has('admins') || has('authentik Admins');
|
||||
show('s-streamers', has('streamers') || isAdmin);
|
||||
show('s-editors', has('editors') || has('media-admins') || isAdmin);
|
||||
show('s-mediaadmins', has('media-admins') || isAdmin);
|
||||
show('s-git', has('git-users') || has('git-admins') || isAdmin);
|
||||
show('s-bot', has('bot-users') || isAdmin);
|
||||
show('s-admins', isAdmin);
|
||||
} catch (e) { $('who').textContent = 'unknown (' + e.message + ')'; }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
114
bin/dumpd.py
114
bin/dumpd.py
@@ -6,6 +6,9 @@ POST /dump identity from X-Authentik-Username / X-Authentik-Groups (set by Cad
|
||||
GET /dump -> lists what a dump would move (dry run)
|
||||
GET /me/stream-token -> short-lived publish token for the caller (member of streamers/admins); the dock
|
||||
and the live portal set it as the OBS/WHIP credential. No stored passwords anywhere.
|
||||
GET /me/clips -> Syncthing self-service status for the caller: our device id, their folder/device state
|
||||
POST /me/clips -> {"device_id": "..."}: create clips/<user> (+ .stignore), add their device to our Syncthing,
|
||||
create the receive-only folder clips-<user> shared with them (min free 300 GB). Idempotent.
|
||||
POST /mtx-auth -> MediaMTX authHTTP hook: allows read/playback; allows publish iff user/pass carries a valid
|
||||
token whose user owns the path prefix. Tokens are HMAC-signed with /etc/vanguard/stream-token.secret.
|
||||
|
||||
@@ -90,6 +93,96 @@ def do_move(files, night):
|
||||
d.rmdir()
|
||||
return moved, size
|
||||
|
||||
# ------------------------------------------------------------------ Syncthing (self-service clip intake)
|
||||
import urllib.request, urllib.error
|
||||
ST_URL = os.environ.get("SYNCTHING_URL", "http://127.0.0.1:8384")
|
||||
ST_CONFIG = Path(os.environ.get("SYNCTHING_CONFIG", "/srv/media/.syncthing/config.xml"))
|
||||
ST_CLIPS_PREFIX = os.environ.get("SYNCTHING_CLIPS_PREFIX", "/var/syncthing/clips") # path INSIDE the syncthing container
|
||||
STIGNORE_SRC = Path(os.environ.get("STIGNORE_SRC", "/etc/vanguard/stignore"))
|
||||
MIN_FREE_GB = int(os.environ.get("CLIPS_MIN_FREE_GB", "300"))
|
||||
|
||||
def st_apikey():
|
||||
try:
|
||||
m = re.search(r"<apikey>([^<]+)</apikey>", ST_CONFIG.read_text())
|
||||
return m.group(1) if m else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def st(method, path, body=None):
|
||||
key = st_apikey()
|
||||
if not key: raise RuntimeError("syncthing api key not found")
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(ST_URL + path, data=data, method=method, headers={"X-API-Key": key, "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
DEVICE_ID_RE = re.compile(r"^[A-Z2-7]{7}(-[A-Z2-7]{7}){7}$")
|
||||
|
||||
def clips_status(user):
|
||||
out = {"user": user, "folder_id": f"clips-{user}", "server_path": f"{ROOT}/clips/{user}"}
|
||||
try:
|
||||
out["server_device_id"] = st("GET", "/rest/system/status")["myID"]
|
||||
cfg_devices = st("GET", "/rest/config/devices")
|
||||
cfg_folders = st("GET", "/rest/config/folders")
|
||||
folder = next((f for f in cfg_folders if f["id"] == out["folder_id"]), None)
|
||||
out["folder_configured"] = folder is not None
|
||||
theirs = [d["deviceID"] for d in (folder or {}).get("devices", []) if d["deviceID"] != out["server_device_id"]]
|
||||
out["device_id"] = theirs[0] if theirs else None
|
||||
if out["device_id"]:
|
||||
conns = st("GET", "/rest/system/connections")["connections"]
|
||||
out["device_connected"] = bool(conns.get(out["device_id"], {}).get("connected"))
|
||||
out["device_name"] = next((d["name"] for d in cfg_devices if d["deviceID"] == out["device_id"]), None)
|
||||
try:
|
||||
comp = st("GET", f"/rest/db/completion?folder={out['folder_id']}&device={out['device_id']}")
|
||||
out["remote_completion"] = comp.get("completion")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stt = st("GET", f"/rest/db/status?folder={out['folder_id']}")
|
||||
out["files_here"] = stt.get("localFiles"); out["state"] = stt.get("state")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
out["error"] = f"syncthing: {e}"
|
||||
out["folder_exists"] = Path(out["server_path"]).is_dir()
|
||||
return out
|
||||
|
||||
def clips_setup(user, device_id, device_name=None):
|
||||
device_id = device_id.strip().upper()
|
||||
if not DEVICE_ID_RE.match(device_id):
|
||||
raise ValueError("that does not look like a Syncthing Device ID (8 groups of 7 characters, e.g. ABCDEFG-HIJKLMN-...)")
|
||||
# 1. server folder with the video-only ignore list
|
||||
d = Path(ROOT) / "clips" / user
|
||||
d.mkdir(parents=True, exist_ok=True); shutil.chown(d, 1000, 1000)
|
||||
ign = d / ".stignore"
|
||||
if not ign.exists() and STIGNORE_SRC.exists():
|
||||
ign.write_text(STIGNORE_SRC.read_text()); shutil.chown(ign, 1000, 1000); ign.chmod(0o644)
|
||||
# 2. their device
|
||||
my_id = st("GET", "/rest/system/status")["myID"]
|
||||
if device_id == my_id: raise ValueError("that is the server's own Device ID — paste yours (your Syncthing → Actions → Show ID)")
|
||||
devices = st("GET", "/rest/config/devices")
|
||||
if not any(x["deviceID"] == device_id for x in devices):
|
||||
st("POST", "/rest/config/devices", {"deviceID": device_id, "name": device_name or user, "addresses": ["dynamic"],
|
||||
"autoAcceptFolders": False, "introducer": False, "paused": False})
|
||||
# 3. receive-only folder shared with them
|
||||
fid = f"clips-{user}"
|
||||
folders = st("GET", "/rest/config/folders")
|
||||
cur = next((f for f in folders if f["id"] == fid), None)
|
||||
spec = {"id": fid, "label": f"Clips — {user}", "path": f"{ST_CLIPS_PREFIX}/{user}", "type": "receiveonly",
|
||||
"devices": [{"deviceID": my_id}, {"deviceID": device_id}],
|
||||
"minDiskFree": {"value": MIN_FREE_GB, "unit": "GB"}, "fsWatcherEnabled": True, "rescanIntervalS": 3600,
|
||||
"ignorePerms": True, "ignoreDelete": False}
|
||||
if cur is None:
|
||||
st("POST", "/rest/config/folders", spec)
|
||||
else:
|
||||
if not any(x["deviceID"] == device_id for x in cur.get("devices", [])):
|
||||
cur["devices"].append({"deviceID": device_id})
|
||||
cur["type"] = "receiveonly"; cur["minDiskFree"] = spec["minDiskFree"]
|
||||
st("PUT", f"/rest/config/folders/{fid}", cur)
|
||||
print(f"{datetime.now(TZ):%F %T} clips setup user={user} device={device_id[:7]}… folder={fid}", flush=True)
|
||||
return clips_status(user)
|
||||
|
||||
def token_secret():
|
||||
try:
|
||||
sec = TOKEN_SECRET_FILE.read_bytes().strip()
|
||||
@@ -164,6 +257,16 @@ class H(BaseHTTPRequestHandler):
|
||||
if self.path.startswith("/time"):
|
||||
# server clock for the timecode strip (the /clock/ page and Go Live measure their offset against this)
|
||||
return self._json(200, {"now_ms": int(_time.time() * 1000)})
|
||||
if self.path == "/me" or self.path == "/me/":
|
||||
who, is_admin = identity(self.headers)
|
||||
if not who: return self._json(401, {"error": "not authenticated"})
|
||||
groups = sorted({g.strip() for g in re.split(r"[|,]", self.headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()})
|
||||
return self._json(200, {"user": who, "groups": groups, "admin": is_admin,
|
||||
"name": self.headers.get("X-Authentik-Name") or who, "email": self.headers.get("X-Authentik-Email") or ""})
|
||||
if self.path.startswith("/me/clips"):
|
||||
who, _ = identity(self.headers)
|
||||
if not who: return self._json(401, {"error": "not authenticated"})
|
||||
return self._json(200, clips_status(who))
|
||||
if self.path.startswith("/me/stream-token") or self.path.startswith("/me/stream-key"):
|
||||
# Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers.
|
||||
who, _ = identity(self.headers)
|
||||
@@ -217,6 +320,17 @@ class H(BaseHTTPRequestHandler):
|
||||
return self._json(401, {"error": "path must be <your-username>/<Title>"})
|
||||
print(f"{datetime.now(TZ):%F %T} mtx-auth allow publish user={user} path={path} ip={req.get('ip')}", flush=True)
|
||||
return self._json(200, {"ok": True})
|
||||
if self.path.startswith("/me/clips"):
|
||||
who, _ = identity(self.headers)
|
||||
if not who: return self._json(401, {"error": "not authenticated"})
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
try:
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
return self._json(200, clips_setup(who, str(body.get("device_id", "")), body.get("device_name")))
|
||||
except ValueError as e:
|
||||
return self._json(400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
return self._json(502, {"error": f"syncthing: {e}"})
|
||||
if not self.path.startswith("/dump"):
|
||||
return self._json(404, {"error": "not found"})
|
||||
u, is_admin = identity(self.headers)
|
||||
|
||||
2
check.sh
2
check.sh
@@ -16,7 +16,7 @@ rm -rf __pycache__ bin/__pycache__
|
||||
python3 -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" docker-compose.yml mediamtx.example.yml && say "yaml" ok || { say "yaml" "INVALID"; fail=1; }
|
||||
node -e "
|
||||
const fs=require('fs');
|
||||
for (const f of ['dock/index.html','dock/clock/index.html']) { const html=fs.readFileSync(f,'utf8'); for (const m of html.matchAll(/<script(?:[^>]*)>([\s\S]*?)<\/script>/g)) if (m[1].trim()) new Function(m[1]); }
|
||||
for (const f of ['dock/index.html','dock/clock/index.html','clips/index.html','about/index.html']) { const html=fs.readFileSync(f,'utf8'); for (const m of html.matchAll(/<script(?:[^>]*)>([\s\S]*?)<\/script>/g)) if (m[1].trim()) new Function(m[1]); }
|
||||
new Function(fs.readFileSync('dock/clock/vgclock.js','utf8'));" && say "dock + clock JS" ok || { say "dock + clock JS" "SYNTAX ERROR"; fail=1; }
|
||||
diff <(sed '$d' live/src/vgclock.js | sed 's/})(typeof window.*/})(window);/') dock/clock/vgclock.js >/dev/null && say "vgclock.js in sync (dock/portal)" ok || say "vgclock.js in sync (dock/portal)" "DIFFERS — copy dock/clock/vgclock.js to live/src and re-add the export line"
|
||||
# generated Caddyfile / mediamtx / copyparty from a dry configure run must at least be produced + parse
|
||||
|
||||
95
clips/index.html
Normal file
95
clips/index.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Vanguard Clips</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:15px/1.5 system-ui,sans-serif}
|
||||
main{max-width:640px;margin:0 auto;padding:24px 16px}
|
||||
h1{font-size:22px;margin:0 0 4px}.sub{color:var(--mut);margin-bottom:20px}
|
||||
.card{background:var(--box);border:1px solid #3a3a3a;border-radius:12px;padding:18px;margin-bottom:14px}
|
||||
.card h2{font-size:15px;margin:0 0 10px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
|
||||
ol{padding-left:20px;margin:8px 0}li{margin:6px 0}
|
||||
code,.id{font-family:ui-monospace,monospace;background:#141414;padding:2px 6px;border-radius:5px;word-break:break-all}
|
||||
.id{display:block;padding:10px;font-size:14px;margin:6px 0}
|
||||
input{width:100%;box-sizing:border-box;padding:10px;border:1px solid #444;border-radius:8px;background:#141414;color:var(--fg);font:14px ui-monospace,monospace}
|
||||
button{padding:10px 14px;border:0;border-radius:8px;background:var(--acc);color:#fff;font-weight:600;cursor:pointer;margin-top:10px}
|
||||
button.small{background:#3a3a3a;font-weight:500;padding:6px 10px;margin:0 0 0 8px;font-size:13px}
|
||||
.status{margin-top:10px;color:var(--mut);white-space:pre-wrap}.ok{color:var(--ok)}.bad{color:var(--bad)}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;background:#333;margin-right:6px}
|
||||
.pill.on{background:#1e4620;color:#9f9}.pill.off{background:#4a1e1e;color:#f99}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Send your clips to Vanguard</h1>
|
||||
<div class="sub">Logged in as <b id="who">…</b>. Your clips land in <code id="path">clips/…</code> and show up in Jellyfin under your name.</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Status</h2>
|
||||
<div id="pills"><span class="pill">loading…</span></div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Install Syncthing on your gaming PC</h2>
|
||||
<ol>
|
||||
<li>Windows: <a href="https://syncthing.net/downloads/" target="_blank" rel="noopener">Syncthing</a> or <a href="https://github.com/Martchus/syncthingtray/releases" target="_blank" rel="noopener">Syncthing Tray</a>. Mac/Linux: Syncthing.</li>
|
||||
<li>Open its page (usually <code>http://localhost:8384</code>) → <b>Actions → Show ID</b>. Copy the Device ID.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Paste your Device ID here</h2>
|
||||
<input id="devid" placeholder="XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX" autocomplete="off" spellcheck="false">
|
||||
<button id="go">Connect my device</button>
|
||||
<div class="status" id="goStatus"></div>
|
||||
<div class="sub" style="margin:10px 0 0">This creates your folder on the server, adds your device, and shares a receive-only folder named <code id="fid">clips-…</code> with you.</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>3. Accept on your PC</h2>
|
||||
<ol>
|
||||
<li>Your Syncthing shows <b>“New Device”</b> from <b>Vanguard</b> → Add Device. Our ID, for checking:
|
||||
<span class="id" id="srvid">…</span><button class="small" id="copyid">copy</button></li>
|
||||
<li>Then <b>“New Folder”</b> <code id="fid2">clips-…</code> → <b>Add</b>. Folder Path = the folder your clips save to
|
||||
(ShadowPlay / Medal / Steam). Folder Type: <b>Send Only</b>. Save.</li>
|
||||
<li>Done. Only video files are pulled; your folder layout is mirrored. The server converts odd formats and
|
||||
keeps one copy per clip.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
async function refresh(){
|
||||
try {
|
||||
const r = await fetch('/me/clips', {credentials:'same-origin'}); const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || r.status);
|
||||
$('who').textContent = d.user; $('path').textContent = 'clips/' + d.user; $('fid').textContent = $('fid2').textContent = d.folder_id;
|
||||
$('srvid').textContent = d.server_device_id || '(syncthing unreachable)';
|
||||
const pills = [];
|
||||
pills.push(`<span class="pill ${d.folder_exists?'on':'off'}">server folder ${d.folder_exists?'ready':'missing'}</span>`);
|
||||
pills.push(`<span class="pill ${d.folder_configured?'on':'off'}">share ${d.folder_configured?'configured':'not yet'}</span>`);
|
||||
if (d.device_id) pills.push(`<span class="pill ${d.device_connected?'on':'off'}">your device ${d.device_connected?'connected':'not connected'}</span>`);
|
||||
if (d.files_here != null) pills.push(`<span class="pill on">${d.files_here} file(s) here</span>`);
|
||||
if (d.remote_completion != null && d.remote_completion < 100) pills.push(`<span class="pill">syncing ${Math.round(d.remote_completion)}%</span>`);
|
||||
$('pills').innerHTML = pills.join('');
|
||||
$('status').textContent = d.error ? d.error : (d.device_id ? `paired with ${d.device_name || 'your device'} (${d.device_id.slice(0,7)}…)` : 'not paired yet — do step 2');
|
||||
if (d.device_id) $('devid').value = d.device_id;
|
||||
} catch (e) { $('pills').innerHTML = `<span class="pill off">error</span>`; $('status').textContent = e.message; $('status').className = 'status bad'; }
|
||||
}
|
||||
$('go').onclick = async () => {
|
||||
$('goStatus').textContent = 'working…'; $('goStatus').className = 'status';
|
||||
try {
|
||||
const r = await fetch('/me/clips', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body: JSON.stringify({device_id: $('devid').value})});
|
||||
const d = await r.json(); if (!r.ok) throw new Error(d.error || r.status);
|
||||
$('goStatus').textContent = 'done — now accept the device and the folder in your Syncthing (step 3)'; $('goStatus').className = 'status ok'; refresh();
|
||||
} catch (e) { $('goStatus').textContent = e.message; $('goStatus').className = 'status bad'; }
|
||||
};
|
||||
$('copyid').onclick = async () => { try { await navigator.clipboard.writeText($('srvid').textContent); $('copyid').textContent = 'copied'; setTimeout(()=>$('copyid').textContent='copy', 1500); } catch {} };
|
||||
refresh(); setInterval(refresh, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
15
configure.py
15
configure.py
@@ -279,6 +279,20 @@ name: {{http.request.header.X-Authentik-Name}}"
|
||||
file_server
|
||||
}}
|
||||
|
||||
# gateway page: role-aware links + instructions (static; talks to /me)
|
||||
redir /about /about/ 308
|
||||
handle_path /about/* {{
|
||||
root * /srv/about
|
||||
file_server
|
||||
}}
|
||||
|
||||
# clip intake self-service (static page; talks to /me/clips)
|
||||
redir /clips /clips/ 308
|
||||
handle_path /clips/* {{
|
||||
root * /srv/clips
|
||||
file_server
|
||||
}}
|
||||
|
||||
# copyparty. It honours X-Authentik-Username / -Groups only when the request also carries a header whose
|
||||
# secret NAME only this proxy knows (copyparty idp-h-key). Clients cannot forge identity.
|
||||
handle {{
|
||||
@@ -472,6 +486,7 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
|
||||
print("== dump users + restart dumpd")
|
||||
run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"])
|
||||
run(["sudo", "install", "-m", "0600", "stream-token.secret", "/etc/vanguard/stream-token.secret"])
|
||||
run(["sudo", "install", "-m", "0644", "syncthing/stignore", "/etc/vanguard/stignore"]) # dumpd copies it into new clips folders
|
||||
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"]
|
||||
|
||||
@@ -54,6 +54,8 @@ services:
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./dock:/srv/dock:ro
|
||||
- ./clips:/srv/clips:ro # clip-intake self-service page
|
||||
- ./about:/srv/about:ro # gateway page (/about)
|
||||
- /srv/live:/srv/live:ro # live portal static build (deployLive.sh)
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
Reference in New Issue
Block a user