Harden LA publishers and add multi-relay guide
Some checks are pending
ci-gates / checks (push) Waiting to run
deploy-cloudflare / checks (push) Waiting to run
deploy-cloudflare / deploy (push) Blocked by required conditions

This commit is contained in:
Conrad Kramer 2026-06-10 01:28:15 -07:00
parent 5d6f77f868
commit cfc4902016
No known key found for this signature in database
13 changed files with 1430 additions and 402 deletions

View file

@ -39,6 +39,41 @@ function normalizeName(s) {
return (s || "").trim(); return (s || "").trim();
} }
function entryRelays(entry) {
const relays = [];
const addRelay = (relay) => {
if (!relay?.relay_url || !relay.broadcast_name) return;
const normalized = {
relay_url: normalizeRelayUrl(relay.relay_url),
broadcast_name: normalizeName(relay.broadcast_name),
track_name: normalizeName(relay.track_name || "video0.m4s"),
};
const key = `${normalized.relay_url}\n${normalized.broadcast_name}\n${normalized.track_name}`;
if (!relays.some((existing) => `${existing.relay_url}\n${existing.broadcast_name}\n${existing.track_name}` === key)) {
relays.push(normalized);
}
};
addRelay({
relay_url: entry?.relay_url,
broadcast_name: entry?.broadcast_name,
track_name: entry?.track_name,
});
if (Array.isArray(entry?.relays)) {
entry.relays.forEach(addRelay);
}
return relays;
}
function entryPrimaryRelay(entry) {
return entryRelays(entry)[0] || {
relay_url: DEFAULT_RELAY_URL,
broadcast_name: normalizeName(entry?.broadcast_name || ""),
track_name: "video0.m4s",
};
}
function currentShareLink(relayUrl, name, mode) { function currentShareLink(relayUrl, name, mode) {
const u = new URL(window.location.href); const u = new URL(window.location.href);
u.pathname = "/watch"; u.pathname = "/watch";
@ -59,6 +94,11 @@ function setHint(text, kind) {
el.dataset.kind = kind || ""; el.dataset.kind = kind || "";
} }
function setNowTitle(text) {
const el = document.getElementById("nowTitle");
if (el) el.textContent = text || "Ready";
}
function setShareLink(text) { function setShareLink(text) {
const el = $("shareLink"); const el = $("shareLink");
el.textContent = text || ""; el.textContent = text || "";
@ -70,6 +110,84 @@ function setListHint(text, kind) {
el.dataset.kind = kind || ""; el.dataset.kind = kind || "";
} }
function openSignalDrawer() {
const drawer = $("signalDrawer");
drawer.open = true;
$("broadcastName").focus();
}
function renderManualTunePrompt(message) {
const mount = $("liveList");
mount.textContent = "";
const empty = document.createElement("div");
empty.className = "emptyState";
const title = document.createElement("div");
title.className = "emptyTitle";
title.textContent = message;
const action = document.createElement("button");
action.className = "secondaryButton";
action.type = "button";
action.textContent = "Add channel";
action.addEventListener("click", openSignalDrawer);
empty.appendChild(title);
empty.appendChild(action);
mount.appendChild(empty);
}
function setThumbPosition(el, index) {
const positions = [
["0%", "0%"],
["100%", "0%"],
["0%", "100%"],
["100%", "100%"],
];
const [x, y] = positions[index % positions.length];
el.style.setProperty("--thumb-x", x);
el.style.setProperty("--thumb-y", y);
}
function renderDefaultMultiview() {
const grid = document.getElementById("multiViewGrid");
if (!grid) return;
const labels = ["News", "Game", "Weather", "Kitchen"];
grid.textContent = "";
labels.forEach((label, index) => {
const button = document.createElement("button");
button.className = `miniScreen tile${String.fromCharCode(65 + index)}`;
button.type = "button";
button.textContent = label;
button.addEventListener("click", () => {
setHint("Add a channel key to tune this slot.", "warn");
openSignalDrawer();
});
grid.appendChild(button);
});
}
function renderMultiview(entries, onWatchLive) {
const grid = document.getElementById("multiViewGrid");
if (!grid) return;
if (!entries.length) {
renderDefaultMultiview();
return;
}
grid.textContent = "";
entries.slice(0, 4).forEach((entry, index) => {
const title = entry.title || entry.stream_id || entry.broadcast_name || "Live";
const button = document.createElement("button");
button.className = "miniScreen";
button.type = "button";
button.textContent = title;
setThumbPosition(button, index);
button.addEventListener("click", () => onWatchLive(entry));
grid.appendChild(button);
});
}
function clearPlayerSignals() { function clearPlayerSignals() {
if (typeof disposePlayerSignals === "function") { if (typeof disposePlayerSignals === "function") {
disposePlayerSignals(); disposePlayerSignals();
@ -110,11 +228,13 @@ function bindPlayerSignals(watch, name, extraCleanup) {
if (status === "loading") { if (status === "loading") {
clearOfflineTimer(); clearOfflineTimer();
sawLoading = true; sawLoading = true;
setNowTitle(name);
setHint(`Tuning ${name}...`, "ok"); setHint(`Tuning ${name}...`, "ok");
return; return;
} }
if (status === "live") { if (status === "live") {
clearOfflineTimer(); clearOfflineTimer();
setNowTitle(name);
setHint(`On air: ${name}`, "ok"); setHint(`On air: ${name}`, "ok");
return; return;
} }
@ -133,6 +253,7 @@ function bindPlayerSignals(watch, name, extraCleanup) {
const hasVideo = Boolean(catalog.video && catalog.video.renditions); const hasVideo = Boolean(catalog.video && catalog.video.renditions);
const hasAudio = Boolean(catalog.audio && catalog.audio.renditions); const hasAudio = Boolean(catalog.audio && catalog.audio.renditions);
if (hasVideo || hasAudio) { if (hasVideo || hasAudio) {
setNowTitle(name);
setHint(`On air: ${name}`, "ok"); setHint(`On air: ${name}`, "ok");
} }
}); });
@ -157,6 +278,7 @@ function mountPlayer(relayUrl, name) {
const mount = $("playerMount"); const mount = $("playerMount");
mount.textContent = ""; mount.textContent = "";
setNowTitle(name);
const watch = document.createElement("moq-watch"); const watch = document.createElement("moq-watch");
watch.setAttribute("url", relayUrl); watch.setAttribute("url", relayUrl);
@ -276,6 +398,7 @@ async function mountArchivePlayer(name) {
const mount = $("playerMount"); const mount = $("playerMount");
mount.textContent = ""; mount.textContent = "";
setNowTitle(name);
const video = document.createElement("video"); const video = document.createElement("video");
video.className = "archiveVideo"; video.className = "archiveVideo";
@ -393,48 +516,60 @@ function renderLiveList(entries, onWatchLive, onWatchArchive) {
mount.textContent = ""; mount.textContent = "";
if (!entries.length) { if (!entries.length) {
setListHint("No stations are on air yet.", ""); setListHint("No stations are on air yet.", "");
renderMultiview([], onWatchLive);
renderManualTunePrompt("No channels found");
return; return;
} }
setListHint(`${entries.length} on air`, "ok"); setListHint(`${entries.length} on air`, "ok");
renderMultiview(entries, onWatchLive);
for (const entry of entries) { for (const [index, entry] of entries.entries()) {
const titleText = entry.title || entry.stream_id || entry.broadcast_name || "Live stream";
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "liveItem"; row.className = "liveItem";
const tuneButton = document.createElement("button");
tuneButton.className = "liveTune";
tuneButton.type = "button";
tuneButton.setAttribute("aria-label", `Watch ${titleText}`);
setThumbPosition(tuneButton, index);
tuneButton.addEventListener("click", () => {
onWatchLive(entry);
});
const info = document.createElement("div"); const info = document.createElement("div");
const title = document.createElement("div"); const title = document.createElement("div");
title.className = "liveTitle"; title.className = "liveTitle";
title.textContent = entry.title || entry.stream_id || entry.broadcast_name || "Live stream"; title.textContent = titleText;
info.appendChild(title); info.appendChild(title);
const meta = document.createElement("div"); const meta = document.createElement("div");
meta.className = "liveMeta"; meta.className = "liveMeta";
meta.textContent = entry.broadcast_name const relayCount = entryRelays(entry).length;
? `Channel key: ${entry.broadcast_name}` const relayText = relayCount > 1 ? ` · ${relayCount} relays` : "";
: "Ready to tune"; meta.textContent = `${entry.broadcast_name || "Ready"}${relayText}`;
info.appendChild(meta); info.appendChild(meta);
const watchBadge = document.createElement("span");
watchBadge.className = "watchBadge";
watchBadge.textContent = "Watch";
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "liveActions"; actions.className = "liveActions";
const watchBtn = document.createElement("button");
watchBtn.className = "btn secondary";
watchBtn.textContent = "Watch";
watchBtn.addEventListener("click", () => {
onWatchLive(entry);
});
const archiveBtn = document.createElement("button"); const archiveBtn = document.createElement("button");
archiveBtn.className = "btn secondary"; archiveBtn.className = "secondaryButton";
archiveBtn.type = "button";
archiveBtn.textContent = "DVR"; archiveBtn.textContent = "DVR";
archiveBtn.addEventListener("click", () => { archiveBtn.addEventListener("click", () => {
onWatchArchive(entry); onWatchArchive(entry);
}); });
actions.appendChild(watchBtn);
actions.appendChild(archiveBtn); actions.appendChild(archiveBtn);
row.appendChild(info); tuneButton.appendChild(info);
tuneButton.appendChild(watchBadge);
row.appendChild(tuneButton);
row.appendChild(actions); row.appendChild(actions);
mount.appendChild(row); mount.appendChild(row);
} }
@ -457,11 +592,30 @@ function main() {
const watchBtn = $("watchBtn"); const watchBtn = $("watchBtn");
const copyBtn = $("copyLinkBtn"); const copyBtn = $("copyLinkBtn");
const refreshListBtn = $("refreshListBtn"); const refreshListBtn = $("refreshListBtn");
const liveModeBtn = $("liveModeBtn");
const dvrModeBtn = $("dvrModeBtn");
const scrubber = $("watchScrubber");
const clipStartBtn = $("clipStartBtn");
const clipEndBtn = $("clipEndBtn");
const copyClipBtn = $("copyClipBtn");
const multiViewBtn = $("multiViewBtn");
const multiView = document.querySelector(".multiview");
let clipStart = Number(scrubber.value || 0);
let clipEnd = Number(scrubber.value || 0);
const initial = readParams(); const initial = readParams();
relayInput.value = initial.relayUrl; relayInput.value = initial.relayUrl;
nameInput.value = initial.name; nameInput.value = initial.name;
archiveModeInput.checked = initial.mode === "archive"; archiveModeInput.checked = initial.mode === "archive";
setNowTitle(initial.name || "Ready");
function updateModeButtons() {
const isArchive = archiveModeInput.checked;
liveModeBtn.classList.toggle("pressed", !isArchive);
dvrModeBtn.classList.toggle("pressed", isArchive);
liveModeBtn.setAttribute("aria-pressed", String(!isArchive));
dvrModeBtn.setAttribute("aria-pressed", String(isArchive));
}
function updateSharePreview() { function updateSharePreview() {
const relayUrl = normalizeRelayUrl(relayInput.value); const relayUrl = normalizeRelayUrl(relayInput.value);
@ -472,6 +626,20 @@ function main() {
return; return;
} }
setShareLink(currentShareLink(relayUrl, name, mode)); setShareLink(currentShareLink(relayUrl, name, mode));
updateModeButtons();
}
function currentClipLink() {
const relayUrl = normalizeRelayUrl(relayInput.value);
const name = normalizeName(nameInput.value);
const mode = archiveModeInput.checked ? "archive" : "live";
if (!name) return "";
const u = new URL(currentShareLink(relayUrl, name, mode));
const start = Math.min(clipStart, clipEnd);
const end = Math.max(clipStart, clipEnd);
u.searchParams.set("clipStart", String(start));
u.searchParams.set("clipEnd", String(end));
return u.toString();
} }
async function start() { async function start() {
@ -483,11 +651,14 @@ function main() {
if (!name) { if (!name) {
setHint("Pick a station or enter a channel key.", "warn"); setHint("Pick a station or enter a channel key.", "warn");
setNowTitle("Ready");
openSignalDrawer();
return; return;
} }
if (mode === "archive") { if (mode === "archive") {
writeParams(relayUrl, name, mode); writeParams(relayUrl, name, mode);
setNowTitle(name);
setHint(`Loading replay: ${name}`, "ok"); setHint(`Loading replay: ${name}`, "ok");
try { try {
await mountArchivePlayer(name); await mountArchivePlayer(name);
@ -502,7 +673,7 @@ function main() {
if (!hasWebTransport()) { if (!hasWebTransport()) {
setHint( setHint(
"This browser cannot tune the live player yet. Try Chrome or Edge.", "Live playback needs WebTransport here. Try DVR replay or another browser.",
"warn", "warn",
); );
return; return;
@ -519,6 +690,7 @@ function main() {
} }
writeParams(relayUrl, name, mode); writeParams(relayUrl, name, mode);
setNowTitle(name);
setHint(`Tuning ${name}...`, "ok"); setHint(`Tuning ${name}...`, "ok");
mountPlayer(relayUrl, name); mountPlayer(relayUrl, name);
} }
@ -526,6 +698,53 @@ function main() {
relayInput.addEventListener("input", updateSharePreview); relayInput.addEventListener("input", updateSharePreview);
nameInput.addEventListener("input", updateSharePreview); nameInput.addEventListener("input", updateSharePreview);
archiveModeInput.addEventListener("input", updateSharePreview); archiveModeInput.addEventListener("input", updateSharePreview);
archiveModeInput.addEventListener("change", updateModeButtons);
liveModeBtn.addEventListener("click", () => {
archiveModeInput.checked = false;
updateSharePreview();
});
dvrModeBtn.addEventListener("click", () => {
archiveModeInput.checked = true;
updateSharePreview();
});
scrubber.addEventListener("input", () => {
setHint(`Marker ${scrubber.value}%`, "");
});
clipStartBtn.addEventListener("click", () => {
clipStart = Number(scrubber.value || 0);
setHint(`Clip starts at ${clipStart}%`, "ok");
});
clipEndBtn.addEventListener("click", () => {
clipEnd = Number(scrubber.value || 0);
setHint(`Clip ends at ${clipEnd}%`, "ok");
});
copyClipBtn.addEventListener("click", async () => {
const link = currentClipLink();
if (!link) {
setHint("Pick a channel first.", "warn");
return;
}
try {
await copyToClipboard(link);
setHint("Clip link copied.", "ok");
setShareLink(link);
} catch (e) {
setHint(`Copy failed: ${String(e)}`, "warn");
setShareLink(link);
}
});
multiViewBtn.addEventListener("click", () => {
const isHidden = multiView?.hasAttribute("hidden");
if (isHidden) {
multiView?.removeAttribute("hidden");
multiViewBtn.classList.add("pressed");
multiViewBtn.setAttribute("aria-pressed", "true");
} else {
multiView?.setAttribute("hidden", "");
multiViewBtn.classList.remove("pressed");
multiViewBtn.setAttribute("aria-pressed", "false");
}
});
watchBtn.addEventListener("click", () => { watchBtn.addEventListener("click", () => {
void start(); void start();
@ -560,23 +779,27 @@ function main() {
renderLiveList( renderLiveList(
entries, entries,
(entry) => { (entry) => {
const relay = entryPrimaryRelay(entry);
archiveModeInput.checked = false; archiveModeInput.checked = false;
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL); relayInput.value = relay.relay_url;
nameInput.value = normalizeName(entry.broadcast_name || ""); nameInput.value = relay.broadcast_name;
updateSharePreview(); updateSharePreview();
void start(); void start();
}, },
(entry) => { (entry) => {
const relay = entryPrimaryRelay(entry);
archiveModeInput.checked = true; archiveModeInput.checked = true;
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL); relayInput.value = relay.relay_url;
nameInput.value = normalizeName(entry.broadcast_name || ""); nameInput.value = relay.broadcast_name;
updateSharePreview(); updateSharePreview();
void start(); void start();
}, },
); );
} catch (e) { } catch (e) {
$("liveList").textContent = ""; $("liveList").textContent = "";
setListHint("Station guide is unavailable right now.", "warn"); setListHint("Station guide is unavailable.", "warn");
renderMultiview([], () => {});
renderManualTunePrompt("Have a channel key?");
} }
} }
refreshListBtn.addEventListener("click", () => { refreshListBtn.addEventListener("click", () => {
@ -584,6 +807,8 @@ function main() {
}); });
updateSharePreview(); updateSharePreview();
updateModeButtons();
renderDefaultMultiview();
void refreshLiveList(); void refreshLiveList();
window.setInterval(() => { window.setInterval(() => {
void refreshLiveList(); void refreshLiveList();

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -4,79 +4,113 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>every.channel</title> <title>every.channel</title>
<meta <meta name="description" content="Watch live television from every.channel." />
name="description"
content="Watch and share free over-the-air TV. Local first, global when you want."
/>
<link data-trunk rel="css" href="style.css" /> <link data-trunk rel="css" href="style.css" />
<link data-trunk rel="copy-file" href="app.js" /> <link data-trunk rel="copy-file" href="app.js" />
<link data-trunk rel="copy-dir" href="assets" /> <link data-trunk rel="copy-dir" href="assets" />
</head> </head>
<body> <body>
<main class="shell"> <main class="watchSurface">
<section class="studio"> <header class="topbar" aria-label="Watch controls">
<header class="masthead"> <a class="brand" href="/" aria-label="every.channel">
<div> <span class="brandLens" aria-hidden="true"></span>
<div class="brand-title">every.channel</div> <span>every.channel</span>
<div class="brand-subtitle">Live television, tuned from real signals.</div> </a>
<label class="quickTune">
<span class="quickTuneLabel">Channel</span>
<input id="broadcastName" class="quickTuneInput" type="text" spellcheck="false" />
</label>
<button id="watchBtn" class="primaryButton" type="button">Watch</button>
<button id="refreshListBtn" class="roundButton" type="button" aria-label="Scan channels">
<span class="scanIcon" aria-hidden="true"></span>
</button>
</header>
<section class="stageGrid">
<section class="playerStack" aria-label="Live player">
<div class="television">
<div id="playerMount" class="mount">
<div class="idleScreen">
<div class="idlePlate">
<span class="playGlyph" aria-hidden="true"></span>
<strong>Pick a channel</strong>
</div>
</div>
</div>
<section class="controlShelf" aria-label="Playback controls">
<div class="nowLine">
<div>
<div class="kicker">Now</div>
<div id="nowTitle" class="nowTitle">Ready</div>
</div>
<div id="hint" class="hint" aria-live="polite"></div>
</div>
<div class="scrubDeck">
<button class="transportButton" type="button" aria-label="Back">
<span class="backGlyph" aria-hidden="true"></span>
</button>
<label class="scrubberWrap" aria-label="Scrub">
<input id="watchScrubber" class="scrubber" type="range" min="0" max="100" value="78" />
<span class="chapterMarks" aria-hidden="true"></span>
</label>
<button class="transportButton" type="button" aria-label="Forward">
<span class="forwardGlyph" aria-hidden="true"></span>
</button>
</div>
<div class="toolRow">
<button id="clipStartBtn" class="toolButton" type="button">Clip start</button>
<button id="clipEndBtn" class="toolButton" type="button">Clip end</button>
<button id="copyClipBtn" class="toolButton" type="button">Copy clip</button>
<button id="multiViewBtn" class="toolButton pressed" type="button">Multiview</button>
</div>
</section>
</div> </div>
<div class="badge">Local broadcast</div>
</header>
<section class="watchDeck"> <section class="multiview" aria-label="Multiview">
<section class="player" aria-label="Live player"> <div id="multiViewGrid" class="multiViewGrid">
<div class="tv"> <button class="miniScreen tileA" type="button">News</button>
<div class="tv-frame"> <button class="miniScreen tileB" type="button">Game</button>
<div id="playerMount" class="mount"></div> <button class="miniScreen tileC" type="button">Weather</button>
<div class="tv-scanlines" aria-hidden="true"></div> <button class="miniScreen tileD" type="button">Kitchen</button>
</div>
</div> </div>
<div class="hint" id="hint"></div>
</section> </section>
<aside class="console" aria-label="Channel console">
<div class="consoleHead">
<div>
<div class="panel-title">On Air</div>
<div class="consoleCopy">Pick a station and the picture starts here.</div>
</div>
<button id="refreshListBtn" class="btn secondary">Scan</button>
</div>
<div class="hint listHint" id="listHint"></div>
<div id="liveList" class="liveList"></div>
<details class="signalDrawer">
<summary>Signal source</summary>
<div class="signalGrid">
<label class="field">
<div class="label">Signal address</div>
<input id="relayUrl" class="input" type="text" spellcheck="false" />
</label>
<label class="field">
<div class="label">Channel key</div>
<input id="broadcastName" class="input" type="text" spellcheck="false" />
</label>
<label class="field checkField">
<div class="checkRow">
<input id="archiveMode" type="checkbox" />
<span>DVR replay</span>
</div>
</label>
<button id="watchBtn" class="btn">Tune in</button>
</div>
<div class="share">
<button id="copyLinkBtn" class="btn secondary">Copy link</button>
<div class="shareLink" id="shareLink" aria-live="polite"></div>
</div>
</details>
</aside>
</section> </section>
</section>
<footer class="foot"> <aside class="channelRail" aria-label="Channels">
<div>AGPLv3</div> <div class="railHead">
<div>Safari support is still experimental.</div> <div>
</footer> <div class="kicker">Channels</div>
<div id="listHint" class="listHint" aria-live="polite"></div>
</div>
<div class="modeSwitch" aria-label="Mode">
<button id="liveModeBtn" class="modeButton pressed" type="button">Live</button>
<button id="dvrModeBtn" class="modeButton" type="button">DVR</button>
</div>
</div>
<div id="liveList" class="liveList"></div>
<details id="signalDrawer" class="signalDrawer">
<summary>Signal</summary>
<div class="signalGrid">
<label class="field">
<span>Address</span>
<input id="relayUrl" class="input" type="text" spellcheck="false" />
</label>
<label class="checkField">
<input id="archiveMode" type="checkbox" />
<span>DVR replay</span>
</label>
<button id="copyLinkBtn" class="secondaryButton" type="button">Copy link</button>
<div id="shareLink" class="shareLink" aria-live="polite"></div>
</div>
</details>
</aside>
</section>
</main> </main>
<script type="module" src="app.js"></script> <script type="module" src="app.js"></script>

File diff suppressed because it is too large Load diff

View file

@ -5116,20 +5116,33 @@ async fn control_resolve(args: ControlResolveArgs) -> Result<()> {
)) ))
} }
fn select_relay_transport_for_web( fn relay_transports_for_web(transports: &[StreamTransportDescriptor]) -> Vec<WebStreamRelay> {
transports: &[StreamTransportDescriptor], transports
) -> Option<(String, String, String)> { .iter()
for transport in transports { .filter_map(|transport| {
if let StreamTransportDescriptor::RelayMoq { if let StreamTransportDescriptor::RelayMoq {
url, url,
broadcast_name, broadcast_name,
track_name, track_name,
} = transport } = transport
{ {
return Some((url.clone(), broadcast_name.clone(), track_name.clone())); Some(WebStreamRelay {
} relay_url: url.clone(),
} broadcast_name: broadcast_name.clone(),
None track_name: track_name.clone(),
})
} else {
None
}
})
.collect()
}
#[derive(Debug, Clone, serde::Serialize)]
struct WebStreamRelay {
relay_url: String,
broadcast_name: String,
track_name: String,
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@ -5139,6 +5152,7 @@ struct WebStreamUpsertReq<'a> {
relay_url: &'a str, relay_url: &'a str,
broadcast_name: &'a str, broadcast_name: &'a str,
track_name: &'a str, track_name: &'a str,
relays: &'a [WebStreamRelay],
expires_ms: u64, expires_ms: u64,
} }
@ -5207,11 +5221,11 @@ async fn control_bridge_web(args: ControlBridgeWebArgs) -> Result<()> {
} }
} }
let Some((relay_url, broadcast_name, track_name)) = let relays = relay_transports_for_web(&announcement.transports);
select_relay_transport_for_web(&announcement.transports) if relays.is_empty() {
else {
continue; continue;
}; }
let primary_relay = &relays[0];
if last_upserted_unix_ms if last_upserted_unix_ms
.get(&stream_id) .get(&stream_id)
@ -5224,9 +5238,10 @@ async fn control_bridge_web(args: ControlBridgeWebArgs) -> Result<()> {
let payload = WebStreamUpsertReq { let payload = WebStreamUpsertReq {
stream_id: &stream_id, stream_id: &stream_id,
title: &announcement.stream.title, title: &announcement.stream.title,
relay_url: &relay_url, relay_url: &primary_relay.relay_url,
broadcast_name: &broadcast_name, broadcast_name: &primary_relay.broadcast_name,
track_name: &track_name, track_name: &primary_relay.track_name,
relays: &relays,
expires_ms: now_unix_ms().saturating_add(ttl_ms), expires_ms: now_unix_ms().saturating_add(ttl_ms),
}; };
@ -5253,8 +5268,9 @@ async fn control_bridge_web(args: ControlBridgeWebArgs) -> Result<()> {
last_upserted_unix_ms.insert(stream_id.clone(), announcement.updated_unix_ms); last_upserted_unix_ms.insert(stream_id.clone(), announcement.updated_unix_ms);
tracing::info!( tracing::info!(
stream = %stream_id, stream = %stream_id,
relay = %relay_url, relay = %primary_relay.relay_url,
broadcast = %broadcast_name, broadcast = %primary_relay.broadcast_name,
relay_count = relays.len(),
"web stream upserted" "web stream upserted"
); );
if args.once { if args.once {
@ -6853,6 +6869,7 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> {
cmd.stdout(Stdio::piped()); cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::inherit()); cmd.stderr(Stdio::inherit());
cmd.kill_on_drop(true);
tracing::info!(input=%args.input, "spawning ffmpeg"); tracing::info!(input=%args.input, "spawning ffmpeg");
let mut child = cmd.spawn().context("failed to spawn ffmpeg")?; let mut child = cmd.spawn().context("failed to spawn ffmpeg")?;
@ -6990,6 +7007,7 @@ async fn nbc_wt_publish(args: NbcWtPublishArgs) -> Result<()> {
cmd.stdin(Stdio::piped()); cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped()); cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::inherit()); cmd.stderr(Stdio::inherit());
cmd.kill_on_drop(true);
tracing::info!( tracing::info!(
source_url = %args.source_url, source_url = %args.source_url,

View file

@ -38,6 +38,31 @@ struct DirectoryList {
entries: Vec<DirectoryEntry>, entries: Vec<DirectoryEntry>,
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PublicStreamRelay {
relay_url: String,
broadcast_name: String,
track_name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PublicStreamEntry {
stream_id: String,
title: String,
relay_url: String,
broadcast_name: String,
track_name: String,
relays: Vec<PublicStreamRelay>,
updated_ms: u64,
expires_ms: u64,
}
#[derive(Clone, Debug, Serialize)]
struct PublicStreamList {
now_ms: u64,
entries: Vec<PublicStreamEntry>,
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
struct HealthResp { struct HealthResp {
ok: bool, ok: bool,
@ -69,10 +94,29 @@ struct AnswerGetReq {
stream_id: String, stream_id: String,
} }
#[derive(Clone, Debug, Deserialize)]
struct StreamUpsertReq {
stream_id: String,
title: String,
relay_url: Option<String>,
broadcast_name: Option<String>,
track_name: Option<String>,
relays: Option<Vec<PublicStreamRelay>>,
expires_ms: Option<u64>,
}
#[derive(Clone, Debug, Serialize)]
struct StreamUpsertResp {
ok: bool,
ttl_ms: u64,
entry: PublicStreamEntry,
}
#[derive(Default)] #[derive(Default)]
struct State { struct State {
entries: HashMap<String, DirectoryEntry>, entries: HashMap<String, DirectoryEntry>,
answers: HashMap<String, AnswerEntry>, answers: HashMap<String, AnswerEntry>,
streams: HashMap<String, PublicStreamEntry>,
} }
fn now_ms() -> u64 { fn now_ms() -> u64 {
@ -100,6 +144,7 @@ fn json_headers() -> HeaderMap {
fn prune_state(state: &mut State, now: u64) { fn prune_state(state: &mut State, now: u64) {
state.entries.retain(|_, v| v.expires_ms > now); state.entries.retain(|_, v| v.expires_ms > now);
state.answers.retain(|_, v| v.expires_ms > now); state.answers.retain(|_, v| v.expires_ms > now);
state.streams.retain(|_, v| v.expires_ms > now);
// Cap growth defensively. This is not spam-resistant; it's a bootstrap rendezvous. // Cap growth defensively. This is not spam-resistant; it's a bootstrap rendezvous.
if state.entries.len() > 200 { if state.entries.len() > 200 {
@ -114,6 +159,12 @@ fn prune_state(state: &mut State, now: u64) {
items.truncate(500); items.truncate(500);
state.answers = items.into_iter().map(|e| (e.stream_id.clone(), e)).collect(); state.answers = items.into_iter().map(|e| (e.stream_id.clone(), e)).collect();
} }
if state.streams.len() > 1000 {
let mut items = state.streams.values().cloned().collect::<Vec<_>>();
items.sort_by_key(|e| std::cmp::Reverse(e.updated_ms));
items.truncate(1000);
state.streams = items.into_iter().map(|e| (e.stream_id.clone(), e)).collect();
}
} }
async fn health() -> impl IntoResponse { async fn health() -> impl IntoResponse {
@ -129,6 +180,118 @@ async fn directory(state: axum::extract::State<Arc<RwLock<State>>>) -> impl Into
(json_headers(), Json(DirectoryList { now_ms: now, entries })) (json_headers(), Json(DirectoryList { now_ms: now, entries }))
} }
fn push_stream_relay(relays: &mut Vec<PublicStreamRelay>, relay: PublicStreamRelay) {
if relay.relay_url.is_empty() || relay.broadcast_name.is_empty() {
return;
}
if relays.iter().any(|existing| {
existing.relay_url == relay.relay_url
&& existing.broadcast_name == relay.broadcast_name
&& existing.track_name == relay.track_name
}) {
return;
}
if relays.len() < 16 {
relays.push(relay);
}
}
fn normalize_stream_relays(body: &StreamUpsertReq) -> Vec<PublicStreamRelay> {
let mut relays = Vec::new();
if let (Some(relay_url), Some(broadcast_name)) = (&body.relay_url, &body.broadcast_name) {
push_stream_relay(
&mut relays,
PublicStreamRelay {
relay_url: clamp_str(relay_url.clone(), 512),
broadcast_name: clamp_str(broadcast_name.clone(), 256),
track_name: clamp_str(
body.track_name
.clone()
.unwrap_or_else(|| "video0.m4s".to_string()),
256,
),
},
);
}
if let Some(body_relays) = &body.relays {
for relay in body_relays {
push_stream_relay(
&mut relays,
PublicStreamRelay {
relay_url: clamp_str(relay.relay_url.clone(), 512),
broadcast_name: clamp_str(relay.broadcast_name.clone(), 256),
track_name: clamp_str(
if relay.track_name.is_empty() {
"video0.m4s".to_string()
} else {
relay.track_name.clone()
},
256,
),
},
);
}
}
relays
}
async fn public_streams(state: axum::extract::State<Arc<RwLock<State>>>) -> impl IntoResponse {
let now = now_ms();
let mut guard = state.write().await;
prune_state(&mut guard, now);
let mut entries = guard.streams.values().cloned().collect::<Vec<_>>();
entries.sort_by_key(|e| std::cmp::Reverse(e.updated_ms));
(json_headers(), Json(PublicStreamList { now_ms: now, entries }))
}
async fn stream_upsert(
state: axum::extract::State<Arc<RwLock<State>>>,
Json(body): Json<StreamUpsertReq>,
) -> impl IntoResponse {
let now = now_ms();
let relays = normalize_stream_relays(&body);
if body.stream_id.is_empty()
|| body.title.is_empty()
|| body.relay_url.as_deref().unwrap_or_default().is_empty()
|| body.broadcast_name.as_deref().unwrap_or_default().is_empty()
{
let resp =
serde_json::json!({ "error": "missing stream_id/title/relay_url/broadcast_name" });
return (StatusCode::BAD_REQUEST, json_headers(), Json(resp)).into_response();
}
let requested_expires = body.expires_ms.unwrap_or(now + 20_000);
let requested_ttl = requested_expires.saturating_sub(now);
let ttl_ms = requested_ttl.clamp(5_000, 60_000);
let primary = relays[0].clone();
let entry = PublicStreamEntry {
stream_id: clamp_str(body.stream_id, 256),
title: clamp_str(body.title, 128),
relay_url: primary.relay_url,
broadcast_name: primary.broadcast_name,
track_name: primary.track_name,
relays,
updated_ms: now,
expires_ms: now + ttl_ms,
};
let mut guard = state.write().await;
prune_state(&mut guard, now);
guard.streams.insert(entry.stream_id.clone(), entry.clone());
(
json_headers(),
Json(StreamUpsertResp {
ok: true,
ttl_ms,
entry,
}),
)
.into_response()
}
async fn announce( async fn announce(
state: axum::extract::State<Arc<RwLock<State>>>, state: axum::extract::State<Arc<RwLock<State>>>,
Json(body): Json<AnnounceReq>, Json(body): Json<AnnounceReq>,
@ -233,6 +396,8 @@ async fn main() -> anyhow::Result<()> {
let app = Router::new() let app = Router::new()
.route("/api/health", get(health)) .route("/api/health", get(health))
.route("/api/directory", get(directory)) .route("/api/directory", get(directory))
.route("/api/public-streams", get(public_streams))
.route("/api/stream-upsert", post(stream_upsert))
.route("/api/announce", post(announce)) .route("/api/announce", post(announce))
.route("/api/answer", post(post_answer).get(get_answer)) .route("/api/answer", post(post_answer).get(get_answer))
.with_state(state) .with_state(state)

View file

@ -212,10 +212,17 @@ type PublicStreamEntry = {
relay_url: string; relay_url: string;
broadcast_name: string; broadcast_name: string;
track_name: string; track_name: string;
relays: PublicStreamRelay[];
updated_ms: number; updated_ms: number;
expires_ms: number; expires_ms: number;
}; };
type PublicStreamRelay = {
relay_url: string;
broadcast_name: string;
track_name: string;
};
type PublicStreamList = { type PublicStreamList = {
now_ms: number; now_ms: number;
entries: PublicStreamEntry[]; entries: PublicStreamEntry[];
@ -323,9 +330,41 @@ type StreamUpsertReq = {
relay_url: string; relay_url: string;
broadcast_name: string; broadcast_name: string;
track_name?: string; track_name?: string;
relays?: PublicStreamRelay[];
expires_ms?: number; expires_ms?: number;
}; };
function publicStreamRelayKey(relay: PublicStreamRelay): string {
return `${relay.relay_url}\n${relay.broadcast_name}\n${relay.track_name}`;
}
function normalizePublicStreamRelays(body: StreamUpsertReq): PublicStreamRelay[] {
const relays: PublicStreamRelay[] = [];
const addRelay = (candidate?: Partial<PublicStreamRelay>) => {
if (!candidate?.relay_url || !candidate.broadcast_name) return;
const relay: PublicStreamRelay = {
relay_url: clampStr(candidate.relay_url, 512),
broadcast_name: clampStr(candidate.broadcast_name, 256),
track_name: clampStr(candidate.track_name || "video0.m4s", 256),
};
if (!relays.some((existing) => publicStreamRelayKey(existing) === publicStreamRelayKey(relay))) {
relays.push(relay);
}
};
addRelay({
relay_url: body.relay_url,
broadcast_name: body.broadcast_name,
track_name: body.track_name,
});
if (Array.isArray(body.relays)) {
for (const relay of body.relays) addRelay(relay);
}
return relays.slice(0, 16);
}
function authBearerToken(request: Request): string | null { function authBearerToken(request: Request): string | null {
const auth = request.headers.get("authorization"); const auth = request.headers.get("authorization");
if (!auth) return null; if (!auth) return null;
@ -392,17 +431,20 @@ export class EcApiContainer implements DurableObject {
{ status: 400 }, { status: 400 },
); );
} }
const relays = normalizePublicStreamRelays(body);
const requestedExpires = body.expires_ms ?? now + 20_000; const requestedExpires = body.expires_ms ?? now + 20_000;
const requestedTtl = Math.max(0, requestedExpires - now); const requestedTtl = Math.max(0, requestedExpires - now);
const ttlMs = Math.min(60_000, Math.max(5_000, requestedTtl)); const ttlMs = Math.min(60_000, Math.max(5_000, requestedTtl));
const primaryRelay = relays[0];
const entry: PublicStreamEntry = { const entry: PublicStreamEntry = {
stream_id: clampStr(body.stream_id, 256), stream_id: clampStr(body.stream_id, 256),
title: clampStr(body.title, 128), title: clampStr(body.title, 128),
relay_url: clampStr(body.relay_url, 512), relay_url: primaryRelay.relay_url,
broadcast_name: clampStr(body.broadcast_name, 256), broadcast_name: primaryRelay.broadcast_name,
track_name: clampStr(body.track_name || "video0.m4s", 256), track_name: primaryRelay.track_name,
relays,
updated_ms: now, updated_ms: now,
expires_ms: now + ttlMs, expires_ms: now + ttlMs,
}; };

View file

@ -0,0 +1,44 @@
# ECP-0120: Material Watch Surface
Status: Draft
## Problem statement
The web watch page should feel like a real television/player surface, not a protocol console,
marketing page, or dark decorative shell. It also needs room for modern watch behaviors: channel
switching, multiview, scrubbing, clipping, direct tuning, and DVR mode.
## Constraints
- Preserve existing live WebTransport playback, DVR replay, public station scanning, share links,
and manual signal tuning.
- Keep the first screen content/player-first and operable with obvious controls.
- Use YouTube as interaction grammar, not as visual branding.
- Keep the palette bright enough for daytime/living-room use.
- Do not rely on browser-specific copy.
## Alternatives considered
- Continue the dark broadcast-console skin from ECP-0118. Rejected because it reads too much like
a control-room hero image and too little like a daily-use player.
- Copy YouTube visually. Rejected because every.channel should inherit watch-page mechanics without
borrowing YouTube brand language.
- Use pure flat CSS. Rejected because the desired direction is a tactile television object with
material depth.
## Decision
Rebuild the static web watch surface around a large video player, right-side channel rail, lower
scrubber, clip controls, and multiview tray. Use generated bitmap material assets for live-preview
tiles and subtle hardware texture, then layer a lighter skeuomorphic system in CSS: warm wood,
brushed metal, smoked acrylic, cream buttons, and broadcast-monitor geometry.
The design lineage is intentionally loose: Braun/Ulm clarity for simple control layout, Sony-style
broadcast monitor seriousness for the player frame, and Bang & Olufsen-style furniture warmth for
the room/object feel. These references are constraints, not objects to copy.
## Rollout / teardown plan
Ship as a static web UI change and validate with desktop/mobile screenshots plus the web build.
Teardown is reverting the HTML/CSS shell to the previous watch page while leaving playback,
directory, and share-link code paths intact.

View file

@ -0,0 +1,50 @@
# ECP-0121: Greedy Multi-Relay Streams
Status: Draft
## Problem statement
Live streams should be publishable to more than one relay at once so viewers can pick the fastest
path and regional relays can mirror each other. The current public directory shape only exposes one
`relay_url` per stream, which encourages duplicate publishers when we want redundancy. For OTA
sources, duplicate publishers are dangerous because each one opens another tuner read.
## Constraints
- Preserve the existing `relay_url`, `broadcast_name`, and `track_name` fields for deployed web,
archive, and manual watch links.
- Keep those primary fields as the compatibility contract; `relays[]` is additive and optional for
consumers.
- Do not duplicate HDHomeRun source reads to get multi-region relay presence.
- Let the public directory advertise multiple relays before every consumer implements racing.
- Keep rollback simple: clients can ignore `relays[]` and keep using the primary legacy fields.
## Decision
Add an ordered `relays[]` candidate list to public stream entries and stream upserts. Stream upserts
continue to require the primary legacy fields; the first relay is mirrored into those fields and
remains the primary/default path. `control-bridge-web` now forwards all relay transports already
present in a control announcement instead of flattening to the first relay only. Current consumers
can keep reading the legacy fields until they explicitly add relay racing.
The intended next step is a single ingest/fanout publisher: read the source once, encode/fragment
once, publish the same stream objects to LAX and NYC relay sessions, and optionally let relays mirror
to each other. Consumers can then race candidates greedily by availability/latency without causing
extra source reads.
## Alternatives considered
- Start one publisher per relay. Rejected because it duplicates source reads and can exhaust physical
tuners, which was the LA outage failure mode.
- Replace the legacy fields with `relays[]`. Rejected because deployed clients and archive workers
already depend on the single-relay shape.
- Accept `relays[]` without primary legacy fields. Rejected because that would make rollback depend
on every publisher being downgraded at the same time as the directory.
- Wait for full relay racing before changing the directory. Rejected because exposing the ordered
candidate set is a small compatible step that unblocks incremental consumers.
## Rollout / teardown plan
Deploy the compatible schema first. Then add publisher fanout and consumer relay racing behind
separate flags. Teardown is removing `relays[]` from upserts and consumers; legacy primary-field
behavior remains intact throughout.

View file

@ -0,0 +1,44 @@
# ECP-0122: Publisher Source Locks And Cgroup Cleanup
Status: Draft
## Problem statement
LA channels disappeared when stale proof/archive publisher helpers kept HDHomeRun tuner HTTP streams
open after the managed publishers restarted. The restarted publishers saw `503 Service Unavailable`
from the tuners, stopped refreshing the public stream directory, and the guide expired to empty.
## Constraints
- A publisher restart must not leave child media processes holding tuners.
- A duplicate publisher on the same node must not open the same physical source URL.
- Keep rollback simple and deployment-owned; no source-device firmware or manual tuner reset should be
required for normal recovery.
## Decision
The NixOS publisher wrapper now takes a non-blocking per-source lock under
`/run/every-channel/source-locks` before launching `ec-node`. If another managed publisher on the
same node is already reading that input URL, the duplicate launch logs and skips instead of opening a
second tuner stream.
Publisher and archive worker services also set explicit `KillMode=control-group`,
`TimeoutStopSec=10s`, and `SendSIGKILL=true`, and archive auto-workers terminate tracked children on
shutdown before systemd's cgroup cleanup runs. The async `wt-publish` and `nbc-wt-publish` ffmpeg
children are marked kill-on-drop so cancelled Rust futures do not strand encoder children.
## Alternatives considered
- Rely on operator cleanup only. Rejected because the failure silently empties the public guide after
TTL expiry.
- Run duplicate publishers for redundancy. Rejected because OTA tuner capacity is the scarce resource;
redundancy should happen after one source read, via publisher fanout and relay mirroring.
- Add only systemd cgroup cleanup. Rejected because it does not prevent two managed units from
intentionally opening the same source at the same time.
## Rollout / teardown plan
Deploy the NixOS module update to every publisher node. Confirm no stale proof/archive helpers remain,
all managed publisher units are active, and `/api/public-streams` lists the expected channels.
Rollback is reverting this module change and redeploying; source locks are runtime files under `/run`
and disappear on reboot.

View file

@ -452,6 +452,7 @@ in
systemd.tmpfiles.rules = systemd.tmpfiles.rules =
[ [
"d /run/every-channel 1777 root root - -" "d /run/every-channel 1777 root root - -"
"d /run/every-channel/source-locks 1777 root root - -"
] ]
++ lib.optionals cfg.nbc.enable [ ++ lib.optionals cfg.nbc.enable [
"d /var/lib/every-channel 0750 every-channel every-channel - -" "d /var/lib/every-channel 0750 every-channel every-channel - -"
@ -487,6 +488,7 @@ in
pkgs.findutils pkgs.findutils
pkgs.gawk pkgs.gawk
pkgs.iproute2 pkgs.iproute2
pkgs.util-linux
cfg.package cfg.package
] ]
++ lib.optionals (isNbc && cfg.nbc.requireMullvad) [ pkgs.mullvad-vpn ] ++ lib.optionals (isNbc && cfg.nbc.requireMullvad) [ pkgs.mullvad-vpn ]
@ -580,8 +582,36 @@ in
return "$status" return "$status"
} }
run_source_command() {
local status source_lock_fd
status=0
source_lock_fd=""
if [[ -n "''${source_lock:-}" ]]; then
exec {source_lock_fd}>"$source_lock"
if ! flock -n "$source_lock_fd"; then
echo "ec-node: source already active on this node, skipping duplicate publisher: $source_id" >&2
exec {source_lock_fd}>&-
return 0
fi
fi
set +e
"$@"
status=$?
set -e
if [[ -n "$source_lock_fd" ]]; then
flock -u "$source_lock_fd" 2>/dev/null || true
exec {source_lock_fd}>&-
fi
return "$status"
}
nbc_url=${lib.escapeShellArg nbcUrlStr} nbc_url=${lib.escapeShellArg nbcUrlStr}
input="" input=""
source_id=""
source_lock=""
if [[ -z "$nbc_url" ]]; then if [[ -z "$nbc_url" ]]; then
explicit_input=${lib.escapeShellArg explicitInputStr} explicit_input=${lib.escapeShellArg explicitInputStr}
if [[ -n "$explicit_input" ]]; then if [[ -n "$explicit_input" ]]; then
@ -676,9 +706,11 @@ in
host="''${hostport%%:*}" host="''${hostport%%:*}"
input="http://$host:5004/auto/v$ch" input="http://$host:5004/auto/v$ch"
fi fi
source_id="$input"
fi fi
if [[ -n "$nbc_url" ]]; then if [[ -n "$nbc_url" ]]; then
source_id="$nbc_url"
cmd=( cmd=(
${lib.escapeShellArg "${cfg.package}/bin/ec-node"} ${lib.escapeShellArg "${cfg.package}/bin/ec-node"}
nbc-wt-publish nbc-wt-publish
@ -715,6 +747,11 @@ in
''} ''}
${extraArgsLine} ${extraArgsLine}
if [[ -n "$source_id" ]]; then
source_key="$(printf '%s' "$source_id" | tr -c 'A-Za-z0-9_.-' '_')"
source_lock="/run/every-channel/source-locks/$source_key.lock"
fi
# Keep the unit alive even if the relay is temporarily unreachable. # Keep the unit alive even if the relay is temporarily unreachable.
# This avoids `switch-to-configuration test` failing due to a unit that exits # This avoids `switch-to-configuration test` failing due to a unit that exits
# quickly during activation. # quickly during activation.
@ -726,9 +763,9 @@ in
continue continue
fi fi
''} ''}
${lib.optionalString (isNbc && cfg.nbc.isolateWithUserNetns) "run_in_user_netns || true"} ${lib.optionalString (isNbc && cfg.nbc.isolateWithUserNetns) "run_source_command run_in_user_netns || true"}
${lib.optionalString (!isNbc || !cfg.nbc.isolateWithUserNetns) '' ${lib.optionalString (!isNbc || !cfg.nbc.isolateWithUserNetns) ''
"''${cmd[@]}" || true run_source_command "''${cmd[@]}" || true
''} ''}
sleep 2 sleep 2
done done
@ -763,6 +800,9 @@ in
ExecStart = "${runner}/bin/${unit}"; ExecStart = "${runner}/bin/${unit}";
Restart = "always"; Restart = "always";
RestartSec = 2; RestartSec = 2;
KillMode = "control-group";
TimeoutStopSec = "10s";
SendSIGKILL = true;
DynamicUser = !isNbc; DynamicUser = !isNbc;
User = lib.mkIf isNbc "every-channel"; User = lib.mkIf isNbc "every-channel";
@ -949,14 +989,24 @@ in
poll_secs="$(awk 'BEGIN { printf "%.3f", ${toString cfg.archive.pollIntervalMs} / 1000.0 }')" poll_secs="$(awk 'BEGIN { printf "%.3f", ${toString cfg.archive.pollIntervalMs} / 1000.0 }')"
cleanup_children() { cleanup_children() {
pids=()
for pid_file in "$pids_dir"/*.pid; do for pid_file in "$pids_dir"/*.pid; do
[[ -e "$pid_file" ]] || continue [[ -e "$pid_file" ]] || continue
pid="$(cat "$pid_file" 2>/dev/null || true)" pid="$(cat "$pid_file" 2>/dev/null || true)"
if [[ -n "$pid" ]]; then if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true pids+=("$pid")
fi fi
rm -f "$pid_file" rm -f "$pid_file"
done done
if [[ "''${#pids[@]}" -gt 0 ]]; then
kill -TERM "''${pids[@]}" 2>/dev/null || true
sleep 1
for pid in "''${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill -KILL "$pid" 2>/dev/null || true
fi
done
fi
} }
trap cleanup_children INT TERM EXIT trap cleanup_children INT TERM EXIT
@ -970,7 +1020,7 @@ in
while IFS= read -r entry; do while IFS= read -r entry; do
name="$(printf '%s\n' "$entry" | jq -r '.broadcast_name // empty')" name="$(printf '%s\n' "$entry" | jq -r '.broadcast_name // empty')"
relay="$(printf '%s\n' "$entry" | jq -r '.relay_url // empty')" relay="$(printf '%s\n' "$entry" | jq -r '(.relay_url // .relays[0].relay_url // empty)')"
if [[ -z "$name" ]]; then if [[ -z "$name" ]]; then
continue continue
fi fi
@ -1039,6 +1089,9 @@ in
ExecStart = "${archiveRunner}/bin/${archiveUnit}"; ExecStart = "${archiveRunner}/bin/${archiveUnit}";
Restart = "always"; Restart = "always";
RestartSec = 2; RestartSec = 2;
KillMode = "control-group";
TimeoutStopSec = "10s";
SendSIGKILL = true;
NoNewPrivileges = true; NoNewPrivileges = true;
PrivateTmp = true; PrivateTmp = true;