WebDAV continues via Authentik app passwords (provider: Intercept header authentication). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
149 lines
7.1 KiB
HTML
149 lines
7.1 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; --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 () => {
|
|
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", {credentials:"same-origin"})).json(); // identity = your Discord login (Authentik)
|
|
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:{"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"); }
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|