Files
Media-Server-Setup/dock/index.html
2026-11-06 19:05:47 -06:00

404 lines
26 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}
/* post-Go-Live health check: what the server is actually receiving */
#health{margin-top:10px;padding:8px;border-radius:6px;font-size:13px;white-space:pre-wrap;word-break:break-word}
#health.hidden{display:none}
#health.bad{background:#43242a;color:#ffd0d0;border:1px solid var(--bad)}
#health.bad code{background:#1a1a1a;padding:1px 5px;border-radius:4px;color:var(--fg)}
#health.ok{color:var(--ok)}
</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>OBS profile to stream from</label><input id="profile" value="Vanguard">
<label style="display:flex;gap:8px;align-items:center;margin-top:8px"><input type="checkbox" id="enforce" style="width:auto;margin:0"> switch OBS to this profile when the dock loads</label>
<div class="who">Encoder settings (B-frames, keyframe interval) live in an OBS profile and obs-websocket cannot change them,
but it can switch profiles. Create one named <b>Vanguard</b> once: Profile → New, then Output → Advanced → your encoder
with <b>B-frames 0</b> and keyframe interval 2 s. Go Live always switches to it; this box also does it on startup.</div>
<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>
<div id="health" class="hidden"></div>
<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","profile"];
const BOOLS = ["enforce"];
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; }); BOOLS.forEach(k => { $(k).checked = localStorage.getItem("vg."+k) === "1"; }); }catch(e){} if(!$("host").value) $("host").value = location.hostname; }
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); BOOLS.forEach(k => localStorage.setItem("vg."+k, $(k).checked ? "1" : "0")); 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; no dots: <title>.webrtc is the audio mirror's name
.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")); };
});
}
// ---- profile enforcement ----------------------------------------------------------------------------
// Encoder settings (B-frames above all: MediaMTX refuses them over WebRTC) cannot be set through
// obs-websocket in Advanced output mode — they live in the profile's streamEncoder.json, which the protocol
// does not expose. Profiles can be switched, though, so the setting is enforced by making sure OBS is on a
// profile that already has it. The post-Go-Live health check then verifies the actual encode.
// Returns true when OBS is on the wanted profile, false (with a status line saying why) otherwise.
async function ensureProfile(o, c) {
if (!c.profile) return true;
const { profiles, currentProfileName } = await o.call("GetProfileList");
if (!profiles.includes(c.profile)) {
status(`OBS has no profile named "${c.profile}". Create it once (Profile → New) with B-frames 0, or clear the name in settings. Streaming on "${currentProfileName}".`, "bad");
return false;
}
if (currentProfileName === c.profile) return true;
const st = await o.call("GetStreamStatus");
if (st.outputActive) { status(`OBS is on "${currentProfileName}" but streaming; not switching mid-stream`, "bad"); return false; }
await o.call("SetCurrentProfile", { profileName: c.profile });
await new Promise(r => setTimeout(r, 800)); // OBS reloads its outputs on a profile switch
status(`OBS switched to profile "${c.profile}"`, "ok");
return true;
}
// ---- 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 ensureProfile(o, c); // the Vanguard profile carries B-frames 0; switch first
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 } });
// B-frames make the stream unwatchable on the portal (MediaMTX refuses them over WebRTC). obs-websocket
// can only set encoder options in Simple output mode with x264, so do that much here; everything else is
// caught after start by checkHealth(), which reads the actual recording on the server.
try {
const mode = (await o.call("GetProfileParameter", { parameterCategory: "Output", parameterName: "Mode" })).parameterValue;
if (mode === "Simple") {
const enc = (await o.call("GetProfileParameter", { parameterCategory: "SimpleOutput", parameterName: "StreamEncoder" })).parameterValue || "";
if (/x264/i.test(enc)) {
const cur = (await o.call("GetProfileParameter", { parameterCategory: "SimpleOutput", parameterName: "x264Settings" })).parameterValue || "";
if (!/\b(bf|bframes)=0\b/.test(cur)) {
await o.call("SetProfileParameter", { parameterCategory: "SimpleOutput", parameterName: "x264Settings", parameterValue: (cur + " bf=0").trim() });
status("set x264 bf=0 in OBS (Simple output) so viewers can watch over WebRTC");
}
}
}
} catch (e) { /* older obs-websocket, or a locked profile: the health check will say if it matters */ }
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");
checkHealth(90);
} catch (e) { status(e.message, "bad"); }
$("golive").disabled = false;
};
// After Go Live, ask the server what it is actually receiving. The recording's first fragments reveal the
// B-frame depth within seconds, and B-frames mean every WebRTC viewer is refused. Polls until the stream
// shows up and is judged, or for `seconds`, whichever comes first.
let healthTimer = null;
async function checkHealth(seconds) {
clearInterval(healthTimer);
const until = Date.now() + seconds * 1000, box = $("health");
box.className = "who"; box.textContent = "checking what the server is receiving…";
healthTimer = setInterval(async () => {
try {
const r = await fetch("/me/stream-health", { credentials: "same-origin" });
if (!r.ok) throw new Error("HTTP " + r.status);
const d = await r.json();
const judged = (d.streams || []).filter(s => s.bframes != null);
if (judged.length) {
clearInterval(healthTimer);
const bad = judged.filter(s => !s.webrtc_ok);
if (bad.length) {
box.className = "bad";
box.innerHTML = `<b>Viewers cannot watch this stream over WebRTC.</b> Your encoder is sending B-frames `
+ `(${bad.map(s => `${s.profile || "H.264"} ${s.res || ""}, B-frame depth ${s.bframes}`).join("; ")}). `
+ `In OBS: Output → Advanced → <b>x264</b>: add <code>bf=0</code> to the options; <b>NVENC</b>: Max B-frames = 0; `
+ `<b>AMF/QSV</b>: B-frames = 0. Then stop and start the stream. The recording itself is fine.`;
} else {
box.className = "ok";
box.textContent = `server receiving ${judged.map(s => `${s.res || ""} ${s.fps ? s.fps + " fps" : ""}`.trim()).join(", ")} with no B-frames — WebRTC viewers are good`;
}
return;
}
} catch (e) { /* not up yet, or the dock is not behind the proxy; keep trying until the deadline */ }
if (Date.now() > until) { clearInterval(healthTimer); box.className = "who"; box.textContent = "could not confirm what the server is receiving (is the stream up?)"; }
}, 6000);
}
$("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; }
// "switch OBS to this profile when the dock loads": inside OBS the dock loads with OBS, so this is startup
if ($("enforce").checked) {
try { const o = await obs(cfg()); await ensureProfile(o, cfg()); o.close(); }
catch (e) { status(`profile check on startup: ${e.message}`, "bad"); }
}
})();
renderPreview();
</script>
</body>
</html>