214 lines
13 KiB
HTML
214 lines
13 KiB
HTML
<!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; --warn:#f1c40f; --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:440px}
|
|
h1{font-size:15px;margin:0 0 10px;color:var(--mut);font-weight:600}
|
|
h1 small{font-weight:400}
|
|
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}
|
|
input[readonly]{color:var(--mut)}
|
|
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.small{width:auto;padding:6px 10px;font-size:13px;margin:6px 6px 0 0;background:#3a3a3a}
|
|
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;border-top:1px solid #333;padding-top:8px}
|
|
summary{color:var(--mut);cursor:pointer;font-size:12px}
|
|
.row{display:flex;gap:8px}
|
|
.row button{margin-top:10px}
|
|
.preview{font-family:ui-monospace,monospace;font-size:12px;color:var(--mut);margin-top:4px;word-break:break-all}
|
|
.preview b{color:var(--fg)}
|
|
.rule{color:var(--bad);font-size:12px;margin-top:4px}
|
|
.kv{font-family:ui-monospace,monospace;font-size:12px;background:var(--box);border-radius:6px;padding:8px;margin-top:6px;word-break:break-all}
|
|
.kv .k{color:var(--mut)}
|
|
.tok{letter-spacing:1px}
|
|
.who{color:var(--mut);font-size:12px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Vanguard Stream <small id="who" class="who"></small>
|
|
<a id="signout" href="/outpost.goauthentik.io/sign_out" class="who" style="float:right;text-decoration:underline" title="sign out of the Discord login (then reload the dock to log in again — needed after a group change)">sign out</a></h1>
|
|
|
|
<label for="title">Stream title</label>
|
|
<input id="title" placeholder="VanguardTraining" autocomplete="off">
|
|
<div class="preview">stream name: <b id="preview">—</b></div>
|
|
<div class="rule" id="rule"></div>
|
|
<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</label><input id="host" placeholder="media.example.com">
|
|
<label>OBS WebSocket password</label><input id="obspw" type="password">
|
|
<label>OBS WebSocket port</label><input id="obsport" value="4455">
|
|
<label>Title prefix (optional, e.g. <code>HelmetCam-</code>)</label><input id="prefix" placeholder="">
|
|
<label>Title suffix (optional; <code>{date}</code> becomes YYYY-MM-DD)</label><input id="suffix" placeholder="-{date}">
|
|
<button id="save">Save settings</button>
|
|
</details>
|
|
|
|
<details id="manual">
|
|
<summary>Manual OBS setup (endpoints + token)</summary>
|
|
<div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div>
|
|
<div class="kv"><span class="k">Stream key </span><span id="m_key">—</span></div>
|
|
<div class="kv"><span class="k">SRT URL </span><span id="m_srt">—</span></div>
|
|
<div class="kv"><span class="k">Token </span><span id="m_tok" class="tok">•••••••••••• (hidden)</span><br><span class="k" id="m_exp"></span></div>
|
|
<button class="small" id="m_show">Show token</button>
|
|
<button class="small" id="m_copy_key">Copy stream key</button>
|
|
<button class="small" id="m_copy_srt">Copy SRT URL</button>
|
|
<button class="small" id="m_fresh">Fresh token (12 h)</button>
|
|
<div class="who" style="margin-top:6px">Tokens are minted for your Discord login and expire; the Go Live button does all of this automatically.</div>
|
|
</details>
|
|
</main>
|
|
|
|
<script>
|
|
// ---- settings --------------------------------------------------------------
|
|
const KEYS = ["host","obspw","obsport","prefix","suffix"];
|
|
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){} if(!$("host").value) $("host").value = location.hostname; }
|
|
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"); } renderManual(); }
|
|
load(); $("save").onclick = save;
|
|
const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()]));
|
|
|
|
// ---- naming rules (must match the server: the sweep publishes <Title>__<timestamp>.mp4) ----------------
|
|
const today = () => { const d = new Date(); return new Date(d.getTime() - d.getTimezoneOffset()*60000).toISOString().slice(0,10); };
|
|
function buildTitle(raw){
|
|
const c = cfg();
|
|
let t = (c.prefix||"") + raw.trim() + (c.suffix||"").replace("{date}", today());
|
|
t = t.replace(/\s+/g, "-") // spaces -> dashes
|
|
.replace(/[^A-Za-z0-9_.-]/g, "") // allowed characters only
|
|
.replace(/_{2,}/g, "_") // "__" is reserved: it separates title from timestamp in filenames
|
|
.replace(/^[.-]+/, "") // no leading dot (hidden) or dash
|
|
.slice(0, 64);
|
|
return t;
|
|
}
|
|
function ruleText(raw){
|
|
const r = [];
|
|
if (/__/.test(raw)) r.push("double underscore is reserved (becomes single)");
|
|
if (/[^A-Za-z0-9_.\- ]/.test(raw)) r.push("only letters, digits, space, _ . - are kept");
|
|
if (raw.trim().startsWith(".")) r.push("cannot start with a dot");
|
|
if ((cfg().prefix + raw + cfg().suffix).length > 64) r.push("longer than 64 characters: trimmed");
|
|
return r.join(" · ");
|
|
}
|
|
let me = null; // {user, token, expires}
|
|
function renderPreview(){
|
|
const t = buildTitle($("title").value);
|
|
$("preview").textContent = t ? `${me ? me.user : "<you>"}/${t}` : "—";
|
|
$("rule").textContent = ruleText($("title").value);
|
|
renderManual(t);
|
|
}
|
|
$("title").addEventListener("input", renderPreview);
|
|
["prefix","suffix"].forEach(k => $(k).addEventListener("input", renderPreview));
|
|
|
|
// ---- identity + tokens (the dock is behind Authentik: same-origin fetch carries the Discord session) -----
|
|
async function whoami(){
|
|
try {
|
|
const r = await fetch("/whoami", {credentials:"same-origin"});
|
|
const m = (await r.text()).match(/user:\s*(\S+)/); if (m) $("who").textContent = "— " + m[1];
|
|
} catch(e){}
|
|
}
|
|
async function mint(){
|
|
const r = await fetch("/me/stream-token", {credentials:"same-origin"});
|
|
const b = await r.json().catch(()=>({}));
|
|
if (!r.ok) throw new Error(b.error || `token ${r.status}`);
|
|
me = b; renderPreview(); return b;
|
|
}
|
|
let showTok = false;
|
|
function renderManual(t){
|
|
const c = cfg(); const title = t ?? buildTitle($("title").value) || "TITLE";
|
|
const user = me ? me.user : "<you>", tok = me ? me.token : "<token>";
|
|
$("m_rtmp").textContent = `rtmp://${c.host||location.hostname}:1935/`;
|
|
$("m_key").textContent = `${user}/${title}?user=${user}&pass=${showTok ? tok : "••••••••"}`;
|
|
$("m_srt").textContent = `srt://${c.host||location.hostname}:8890?streamid=publish:${user}/${title}:${user}:${showTok ? tok : "••••••••"}`;
|
|
$("m_tok").textContent = me ? (showTok ? me.token : "•••••••••••• (hidden)") : "— click “Fresh token”";
|
|
$("m_exp").textContent = me ? `valid until ${new Date(me.expires*1000).toLocaleString()}` : "";
|
|
}
|
|
$("m_show").onclick = () => { showTok = !showTok; $("m_show").textContent = showTok ? "Hide token" : "Show token"; renderManual(); };
|
|
$("m_fresh").onclick = async () => { try { await mint(); status("fresh token minted (12 h)", "ok"); } catch(e){ status(e.message, "bad"); } };
|
|
async function copy(text, what){ try { await navigator.clipboard.writeText(text); status(`${what} copied`, "ok"); } catch(e){ status("clipboard blocked — select the text manually", "bad"); } }
|
|
$("m_copy_key").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`${me.user}/${buildTitle($("title").value)||"TITLE"}?user=${me.user}&pass=${me.token}`, "stream key"); };
|
|
$("m_copy_srt").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`srt://${cfg().host}:8890?streamid=publish:${me.user}/${buildTitle($("title").value)||"TITLE"}:${me.user}:${me.token}`, "SRT URL"); };
|
|
$("manual").addEventListener("toggle", () => { if ($("manual").open) renderManual(); });
|
|
|
|
// ---- 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(c){
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${c.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) {
|
|
let auth;
|
|
if (m.d.authentication) { const secret = await sha256b64(c.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) {
|
|
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) {
|
|
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 = buildTitle($("title").value);
|
|
if (!title) return status("type a title first", "bad");
|
|
if (!c.host) return status("set the ingest host in settings", "bad");
|
|
$("golive").disabled = true;
|
|
try {
|
|
status("getting a stream token…");
|
|
const t = await mint(); // fresh token every Go Live; nothing stored
|
|
status("connecting to OBS…");
|
|
const o = await obs(c);
|
|
await o.call("SetStreamServiceSettings", { streamServiceType: "rtmp_custom",
|
|
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key: `${t.user}/${title}?user=${encodeURIComponent(t.user)}&pass=${encodeURIComponent(t.token)}`, 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 → ${t.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 () => {
|
|
try {
|
|
if (Date.now() - armed > 10000) {
|
|
status("checking what tonight contains…");
|
|
const dry = await (await fetch("/dump", {credentials:"same-origin"})).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 an admin).`);
|
|
}
|
|
armed = 0; $("dump").textContent = "Dump tonight";
|
|
const r = await (await fetch("/dump", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}", credentials:"same-origin"})).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"); }
|
|
};
|
|
|
|
whoami(); renderPreview();
|
|
</script>
|
|
</body>
|
|
</html>
|