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.
286 lines
8.8 KiB
JavaScript
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) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
}[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);
|
|
})();
|