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.
This commit is contained in:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
286
apps/streamd/internal/ui/app.js
Normal file
286
apps/streamd/internal/ui/app.js
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
(() => {
|
||||
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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[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);
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue