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
106
apps/streamd/internal/ui/app.css
Normal file
106
apps/streamd/internal/ui/app.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
:root {
|
||||
--bg: #0f1115;
|
||||
--card: #171a21;
|
||||
--border: #2a303c;
|
||||
--text: #e8eaed;
|
||||
--muted: #9aa0a6;
|
||||
--accent: #7aa2ff;
|
||||
--ok: #3dd68c;
|
||||
--degraded: #f5a524;
|
||||
--down: #f07178;
|
||||
--stopped: #6b7280;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.45 system-ui, Segoe UI, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
|
||||
.header-right { display: flex; gap: 16px; align-items: center; }
|
||||
.muted { color: var(--muted); }
|
||||
.card {
|
||||
margin: 16px 20px;
|
||||
padding: 16px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
.span2 { grid-column: span 2; }
|
||||
label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
input, select, button, textarea {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: #0c0e12;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
resize: vertical;
|
||||
min-height: 4.5em;
|
||||
white-space: pre;
|
||||
}
|
||||
button {
|
||||
background: #243044;
|
||||
border-color: #334155;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.ghost { background: transparent; }
|
||||
button.danger { border-color: #7f1d1d; color: #fecaca; }
|
||||
.actions { display: flex; gap: 8px; align-items: end; }
|
||||
.token-box input { width: 180px; }
|
||||
.row-between { display: flex; justify-content: space-between; align-items: center; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
th { color: var(--muted); font-weight: 500; font-size: 12px; }
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: #222;
|
||||
}
|
||||
.pill.ok { color: var(--ok); }
|
||||
.pill.degraded { color: var(--degraded); }
|
||||
.pill.down, .pill.starting { color: var(--down); }
|
||||
.pill.stopped { color: var(--stopped); }
|
||||
.pill.claim { color: #c4b5fd; background: #1f1635; }
|
||||
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.row-actions button { padding: 4px 8px; font-size: 12px; }
|
||||
.msg { min-height: 1.2em; margin: 8px 0 0; }
|
||||
.msg.err { color: var(--down); }
|
||||
a { color: var(--accent); }
|
||||
dialog {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
min-width: min(560px, 92vw);
|
||||
}
|
||||
dialog::backdrop { background: rgba(0,0,0,.55); }
|
||||
@media (max-width: 720px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.span2 { grid-column: span 1; }
|
||||
header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
}
|
||||
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);
|
||||
})();
|
||||
84
apps/streamd/internal/ui/index.html
Normal file
84
apps/streamd/internal/ui/index.html
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>streamd</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>streamd</h1>
|
||||
<div class="header-right">
|
||||
<span id="uptime" class="muted"></span>
|
||||
<label class="token-box">token <input id="token" type="password" placeholder="STREAMD_TOKEN" autocomplete="off" /></label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<h2>Add stream</h2>
|
||||
<form id="create-form" class="grid">
|
||||
<label>name <input name="name" required placeholder="my-channel" /></label>
|
||||
<label>title <input name="title" placeholder="My Channel" /></label>
|
||||
<label>app
|
||||
<select name="app" id="app-select"></select>
|
||||
</label>
|
||||
<label>channel
|
||||
<select name="channel" id="channel-select"></select>
|
||||
</label>
|
||||
<label class="span2">mpd <input name="mpd" placeholder="https://…/manifest.mpd (optional until capture)" /></label>
|
||||
<label class="span2">key <textarea name="key" rows="4" placeholder="KID:KEY (one per line; multi-key streams need every key)" spellcheck="false"></textarea></label>
|
||||
<label class="span2">headers JSON <input name="headers_json" value="{}" /></label>
|
||||
<div class="span2 actions">
|
||||
<button type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="form-msg" class="msg"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="row-between">
|
||||
<h2>Streams</h2>
|
||||
<div class="row-actions">
|
||||
<span id="agents-status" class="muted">agents: —</span>
|
||||
<button type="button" id="refresh" class="ghost">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th>app / channel</th>
|
||||
<th>health</th>
|
||||
<th>claim</th>
|
||||
<th>age</th>
|
||||
<th>play</th>
|
||||
<th>actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="list-msg" class="msg muted">Loading…</p>
|
||||
</section>
|
||||
|
||||
<dialog id="edit-dlg">
|
||||
<form method="dialog" id="edit-form" class="grid">
|
||||
<h2 class="span2">Edit stream</h2>
|
||||
<input type="hidden" name="id" />
|
||||
<label>title <input name="title" /></label>
|
||||
<label>channel <input name="channel" /></label>
|
||||
<label class="span2">mpd <input name="mpd" /></label>
|
||||
<label class="span2">key <textarea name="key" rows="4" placeholder="KID:KEY (one per line)" spellcheck="false"></textarea></label>
|
||||
<label class="span2">headers JSON <input name="headers_json" /></label>
|
||||
<div class="span2 actions">
|
||||
<button type="submit" value="save">Save</button>
|
||||
<button type="button" id="edit-cancel" class="ghost">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
apps/streamd/internal/ui/ui.go
Normal file
34
apps/streamd/internal/ui/ui.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed index.html app.css app.js
|
||||
var files embed.FS
|
||||
|
||||
func Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
b, err := files.ReadFile("index.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
})
|
||||
}
|
||||
|
||||
func Static() http.Handler {
|
||||
sub, err := fs.Sub(files, ".")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return http.FileServer(http.FS(sub))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue