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:
2026-10-30 15:43:30 -05:00
commit c602a6ff52
66 changed files with 4229 additions and 0 deletions

151
dock/index.html Normal file
View 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
View 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; }