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:
404errordeveloper 2026-10-06 00:25:35 +02:00
commit 2fa8f2435f
121 changed files with 17802 additions and 0 deletions

View 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; }
}

View 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) => ({
"&": "&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);
})();

View 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>

View 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))
}