Null-DRM-Official/apps/streamd/internal/ui/app.js
404errordeveloper 2fa8f2435f Initial commit: Null DRM Official
Capture, decrypt, and restream toolkit with compiled-in app modules
(RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www.
BBC module.yaml is published (clear streams); other module values stay local.
2026-10-06 00:25:35 +02:00

286 lines
8.8 KiB
JavaScript

(() => {
const tokenEl = document.getElementById("token");
const rowsEl = document.getElementById("rows");
const listMsg = document.getElementById("list-msg");
const formMsg = document.getElementById("form-msg");
const uptimeEl = document.getElementById("uptime");
const agentsEl = document.getElementById("agents-status");
const dlg = document.getElementById("edit-dlg");
const editForm = document.getElementById("edit-form");
tokenEl.value = localStorage.getItem("streamd_token") || "";
tokenEl.addEventListener("change", () => {
localStorage.setItem("streamd_token", tokenEl.value.trim());
connectUIWS();
});
function token() {
return tokenEl.value.trim();
}
function headers(json) {
const h = {};
if (json) h["Content-Type"] = "application/json";
const t = token();
if (t) h["Authorization"] = "Bearer " + t;
return h;
}
async function api(path, opts = {}) {
const res = await fetch(path, opts);
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
if (!res.ok) {
const msg = (data && data.error) || res.statusText || "request failed";
throw new Error(msg);
}
return data;
}
function age(s) {
if (s == null || s === 0) return "—";
return Number(s).toFixed(1) + "s";
}
function claimLabel(s) {
if (!s.claimed_by) return '<span class="muted">—</span>';
let exp = "";
if (s.claim_expires_at) {
const ms = Date.parse(s.claim_expires_at) - Date.now();
if (!Number.isNaN(ms)) {
exp = ms > 0 ? ` (${Math.ceil(ms / 1000)}s)` : " (expired)";
}
}
return `<span class="pill claim">${esc(s.claimed_by)}${esc(exp)}</span>`;
}
function render(streams) {
if (!streams.length) {
rowsEl.innerHTML = "";
listMsg.textContent = "No streams yet. Add one above.";
return;
}
listMsg.textContent = streams.length + " stream(s)";
rowsEl.innerHTML = streams.map((s) => {
const need = !s.mpd || !s.key ? " needs capture" : "";
return `<tr>
<td><strong>${esc(s.name)}</strong><div class="muted">${esc(s.title || "")}${need}</div></td>
<td>${esc(s.app)} / ${esc(s.channel)}</td>
<td><span class="pill ${esc(s.health || "stopped")}">${esc(s.health || "stopped")}</span></td>
<td>${claimLabel(s)}</td>
<td>${age(s.playlist_age_s)}</td>
<td><a href="${esc(s.play_path)}" target="_blank" rel="noopener">hls</a></td>
<td class="row-actions">
<button data-act="start" data-id="${s.id}">Start</button>
<button data-act="stop" data-id="${s.id}" class="ghost">Stop</button>
<button data-act="restart" data-id="${s.id}" class="ghost">Restart</button>
<button data-act="edit" data-id="${s.id}" class="ghost">Edit</button>
${s.claimed_by ? `<button data-act="unclaim" data-id="${s.id}" class="ghost">Release</button>` : ""}
<button data-act="delete" data-id="${s.id}" class="danger">Delete</button>
</td>
</tr>`;
}).join("");
}
function esc(v) {
return String(v ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[c]));
}
function renderAgents(list) {
if (!agentsEl) return;
if (!list || !list.length) {
agentsEl.textContent = "agents: none connected";
return;
}
agentsEl.textContent = "agents: " + list.map((a) => a.agent_id).join(", ");
}
async function refresh() {
try {
const [health, list, agents] = await Promise.all([
api("/api/health"),
api("/api/streams"),
api("/api/agents").catch(() => ({ agents: [] })),
]);
uptimeEl.textContent = "up " + Math.floor(health.uptime_s || 0) + "s";
render(list.streams || []);
renderAgents(agents.agents || []);
listMsg.classList.remove("err");
} catch (e) {
listMsg.textContent = e.message;
listMsg.classList.add("err");
}
}
// App/channel pickers come from the binary's compiled-in modules, so the UI
// never hardcodes a provider.
const appSel = document.getElementById("app-select");
const chanSel = document.getElementById("channel-select");
let apps = [];
function fillChannels() {
const a = apps.find((x) => x.name === appSel.value);
chanSel.innerHTML = "";
if (!a || !a.channels.length) {
const o = document.createElement("option");
o.value = "";
o.textContent = a ? "(no module.yaml — no channels)" : "(no apps)";
chanSel.appendChild(o);
return;
}
for (const ch of a.channels) {
const o = document.createElement("option");
o.value = ch.id;
o.textContent = ch.label && ch.label !== ch.id ? ch.id + " — " + ch.label : ch.id;
chanSel.appendChild(o);
}
}
async function loadApps() {
try {
const data = await api("/api/apps");
apps = data.apps || [];
} catch (e) {
apps = [];
}
appSel.innerHTML = "";
if (!apps.length) {
const o = document.createElement("option");
o.value = "";
o.textContent = "(no app modules compiled in)";
appSel.appendChild(o);
}
for (const a of apps) {
const o = document.createElement("option");
o.value = a.name;
o.textContent = a.name;
appSel.appendChild(o);
}
fillChannels();
}
appSel.addEventListener("change", fillChannels);
loadApps();
document.getElementById("refresh").addEventListener("click", refresh);
document.getElementById("create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
formMsg.textContent = "";
formMsg.classList.remove("err");
const fd = new FormData(ev.target);
const body = Object.fromEntries(fd.entries());
try {
await api("/api/streams", {
method: "POST",
headers: headers(true),
body: JSON.stringify(body),
});
ev.target.reset();
ev.target.headers_json.value = "{}";
fillChannels();
formMsg.textContent = "Created.";
refresh();
} catch (e) {
formMsg.textContent = e.message;
formMsg.classList.add("err");
}
});
rowsEl.addEventListener("click", async (ev) => {
const btn = ev.target.closest("button[data-act]");
if (!btn) return;
const id = btn.dataset.id;
const act = btn.dataset.act;
try {
if (act === "delete") {
if (!confirm("Delete stream #" + id + "?")) return;
await api("/api/streams/" + id, { method: "DELETE", headers: headers(false) });
} else if (act === "unclaim") {
await api("/api/streams/" + id + "/claim/release", {
method: "POST",
headers: headers(true),
body: JSON.stringify({ agent_id: "" }),
});
} else if (act === "edit") {
const st = await api("/api/streams/" + id);
editForm.id.value = st.id;
editForm.title.value = st.title || "";
editForm.channel.value = st.channel || "";
editForm.mpd.value = st.mpd || "";
editForm.key.value = st.key || "";
editForm.headers_json.value = st.headers_json || "{}";
dlg.showModal();
return;
} else {
await api("/api/streams/" + id + "/" + act, {
method: "POST",
headers: headers(false),
});
}
refresh();
} catch (e) {
listMsg.textContent = e.message;
listMsg.classList.add("err");
}
});
document.getElementById("edit-cancel").addEventListener("click", () => dlg.close());
editForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const id = editForm.id.value;
const body = {
title: editForm.title.value,
channel: editForm.channel.value,
mpd: editForm.mpd.value,
key: editForm.key.value,
headers_json: editForm.headers_json.value,
};
try {
await api("/api/streams/" + id, {
method: "PATCH",
headers: headers(true),
body: JSON.stringify(body),
});
dlg.close();
refresh();
} catch (e) {
alert(e.message);
}
});
let uiWS = null;
let uiWSTimer = null;
function connectUIWS() {
if (uiWS) {
try { uiWS.close(); } catch {}
uiWS = null;
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(proto + "//" + location.host + "/ws/ui");
uiWS = ws;
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "agents" || msg.type === "agent_online" || msg.type === "agent_offline") {
renderAgents(msg.agents || []);
}
if (msg.type === "claims_changed" || msg.type === "agent_offline") {
refresh();
}
} catch {}
};
ws.onclose = () => {
clearTimeout(uiWSTimer);
uiWSTimer = setTimeout(connectUIWS, 3000);
};
}
refresh();
connectUIWS();
setInterval(refresh, 5000);
})();