Files
Media-Server-Setup/dock/index.html

312 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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="#" class="who" style="float:right;text-decoration:underline" title="sign out of the Discord login and come back to the dock (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="tcband">
<summary>Timecode band (frame-accurate multicam sync)</summary>
<div class="who" style="margin-top:6px">Adds a thin "overscan" band below your canvas with the server clock encoded in it.
The server decodes it after each stream so editors get a frame-accurate timecode track; the live portal and
editors crop the band, Jellyfin shows it as a thin strip at the bottom. OBS must not be streaming or recording.</div>
<div class="kv" id="tc_state">canvas: — · band: —</div>
<button class="small" id="tc_add">Add band + clock source</button>
<button class="small" id="tc_scene">Add clock to current scene</button>
<button class="small" id="tc_remove">Remove band</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")); };
});
}
// ---- timecode band (obs-websocket) -------------------------------------------------------------------
const CLOCK_INPUT = "Vanguard Clock";
const bandFor = w => { let b = Math.round(w / 160); return b + (b % 2); };
async function tcState() {
try {
const o = await obs(cfg());
const v = await o.call("GetVideoSettings");
const inputs = await o.call("GetInputList", { inputKind: "browser_source" });
const has = inputs.inputs.some(i => i.inputName === CLOCK_INPUT);
const band = bandFor(v.baseWidth);
const hasBand = (v.baseHeight % 2 === 0) && [720, 1080, 1440, 2160].some(h => v.baseHeight === h + bandFor(v.baseWidth));
o.close();
$("tc_state").textContent = `canvas: ${v.baseWidth}×${v.baseHeight} (output ${v.outputWidth}×${v.outputHeight}) · band: ${hasBand ? band + " px present" : "not set"} · clock source: ${has ? "present" : "absent"}`;
return { v, has, band, hasBand };
} catch (e) { $("tc_state").textContent = "OBS not reachable: " + e.message; return null; }
}
async function notStreaming(o) {
const s = await o.call("GetStreamStatus"), r = await o.call("GetRecordStatus");
if (s.outputActive || r.outputActive) throw new Error("stop streaming/recording first — the canvas size cannot change while an output is active");
}
async function ensureClockInput(o, width, band, scene) {
const url = `${location.origin}/dock/clock/`;
const inputs = await o.call("GetInputList", { inputKind: "browser_source" });
const settings = { url, width, height: band, fps_custom: true, fps: 60, shutdown: false, restart_when_active: false, reroute_audio: false };
if (!inputs.inputs.some(i => i.inputName === CLOCK_INPUT)) {
await o.call("CreateInput", { sceneName: scene, inputName: CLOCK_INPUT, inputKind: "browser_source", inputSettings: settings, sceneItemEnabled: true });
} else {
await o.call("SetInputSettings", { inputName: CLOCK_INPUT, inputSettings: settings, overlay: true });
const items = await o.call("GetSceneItemList", { sceneName: scene });
if (!items.sceneItems.some(i => i.sourceName === CLOCK_INPUT)) await o.call("CreateSceneItem", { sceneName: scene, sourceName: CLOCK_INPUT, sceneItemEnabled: true });
}
const items = await o.call("GetSceneItemList", { sceneName: scene });
const it = items.sceneItems.find(i => i.sourceName === CLOCK_INPUT);
if (it) {
const v = await o.call("GetVideoSettings");
await o.call("SetSceneItemTransform", { sceneName: scene, sceneItemId: it.sceneItemId,
sceneItemTransform: { positionX: 0, positionY: v.baseHeight - band, scaleX: 1, scaleY: 1, boundsType: "OBS_BOUNDS_NONE", cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0 } });
await o.call("SetSceneItemLocked", { sceneName: scene, sceneItemId: it.sceneItemId, sceneItemLocked: true });
}
}
$("tc_add").onclick = async () => {
try {
const o = await obs(cfg()); await notStreaming(o);
const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
const gameH = [720, 1080, 1440, 2160].find(h => v.baseHeight === h + band) ? v.baseHeight - band : v.baseHeight;
const scale = v.outputHeight / v.baseHeight;
await o.call("SetVideoSettings", { baseWidth: v.baseWidth, baseHeight: gameH + band, outputWidth: v.outputWidth, outputHeight: Math.round((gameH + band) * scale / 2) * 2 });
const scene = (await o.call("GetCurrentProgramScene")).currentProgramSceneName;
await ensureClockInput(o, v.baseWidth, band, scene); o.close();
status(`band added: canvas ${v.baseWidth}×${gameH + band}, clock source in "${scene}"`, "ok"); tcState();
} catch (e) { status(e.message, "bad"); }
};
$("tc_scene").onclick = async () => {
try {
const o = await obs(cfg()); const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
const scene = (await o.call("GetCurrentProgramScene")).currentProgramSceneName;
await ensureClockInput(o, v.baseWidth, band, scene); o.close(); status(`clock source placed in "${scene}"`, "ok"); tcState();
} catch (e) { status(e.message, "bad"); }
};
$("tc_remove").onclick = async () => {
try {
const o = await obs(cfg()); await notStreaming(o);
const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
const gameH = [720, 1080, 1440, 2160].find(h => v.baseHeight === h + band) ? v.baseHeight - band : v.baseHeight;
try { await o.call("RemoveInput", { inputName: CLOCK_INPUT }); } catch {}
const scale = v.outputHeight / v.baseHeight;
await o.call("SetVideoSettings", { baseWidth: v.baseWidth, baseHeight: gameH, outputWidth: v.outputWidth, outputHeight: Math.round(gameH * scale / 2) * 2 });
o.close(); status(`band removed: canvas ${v.baseWidth}×${gameH}`, "ok"); tcState();
} catch (e) { status(e.message, "bad"); }
};
$("tcband").addEventListener("toggle", () => { if ($("tcband").open) tcState(); });
// ---- 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"); }
};
$("signout").onclick = async e => {
e.preventDefault(); status("signing out…");
// Authentik's sign-out redirects to the site root (copyparty); do it in the background, then reload the dock,
// which sends us through the Discord login and back here.
try { await fetch("/outpost.goauthentik.io/sign_out", {credentials:"same-origin", mode:"no-cors", redirect:"follow"}); } catch(e){}
// also end the Authentik login itself (otherwise the next visit re-authorises silently); the flow returns to this host
const auth = "https://" + location.hostname.replace(/^[^.]+\./, "auth.") + "/if/flow/default-invalidation-flow/?next=" + encodeURIComponent(location.origin + "/dock/");
setTimeout(() => location.replace(auth), 400);
};
// On load: who am I, and mint a token right away so the preview and the manual section show real values.
(async () => {
await whoami();
try { await mint(); status("ready"); }
catch (e) { status(e.message, "bad"); $("m_tok").textContent = "— " + e.message; }
})();
renderPreview();
</script>
</body>
</html>