Harden LA publishers and add multi-relay guide
This commit is contained in:
parent
5d6f77f868
commit
cfc4902016
13 changed files with 1430 additions and 402 deletions
267
apps/web/app.js
267
apps/web/app.js
|
|
@ -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();
|
||||||
|
|
|
||||||
BIN
apps/web/assets/live-preview-mosaic.png
Normal file
BIN
apps/web/assets/live-preview-mosaic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/assets/tv-control-material.png
Normal file
BIN
apps/web/assets/tv-control-material.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
|
|
@ -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>
|
||||||
</div>
|
|
||||||
<div class="badge">Local broadcast</div>
|
<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>
|
</header>
|
||||||
|
|
||||||
<section class="watchDeck">
|
<section class="stageGrid">
|
||||||
<section class="player" aria-label="Live player">
|
<section class="playerStack" aria-label="Live player">
|
||||||
<div class="tv">
|
<div class="television">
|
||||||
<div class="tv-frame">
|
<div id="playerMount" class="mount">
|
||||||
<div id="playerMount" class="mount"></div>
|
<div class="idleScreen">
|
||||||
<div class="tv-scanlines" aria-hidden="true"></div>
|
<div class="idlePlate">
|
||||||
|
<span class="playGlyph" aria-hidden="true"></span>
|
||||||
|
<strong>Pick a channel</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" id="hint"></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>
|
||||||
|
|
||||||
|
<section class="multiview" aria-label="Multiview">
|
||||||
|
<div id="multiViewGrid" class="multiViewGrid">
|
||||||
|
<button class="miniScreen tileA" type="button">News</button>
|
||||||
|
<button class="miniScreen tileB" type="button">Game</button>
|
||||||
|
<button class="miniScreen tileC" type="button">Weather</button>
|
||||||
|
<button class="miniScreen tileD" type="button">Kitchen</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="console" aria-label="Channel console">
|
<aside class="channelRail" aria-label="Channels">
|
||||||
<div class="consoleHead">
|
<div class="railHead">
|
||||||
<div>
|
<div>
|
||||||
<div class="panel-title">On Air</div>
|
<div class="kicker">Channels</div>
|
||||||
<div class="consoleCopy">Pick a station and the picture starts here.</div>
|
<div id="listHint" class="listHint" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
<button id="refreshListBtn" class="btn secondary">Scan</button>
|
<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 class="hint listHint" id="listHint"></div>
|
</div>
|
||||||
|
|
||||||
<div id="liveList" class="liveList"></div>
|
<div id="liveList" class="liveList"></div>
|
||||||
|
|
||||||
<details class="signalDrawer">
|
<details id="signalDrawer" class="signalDrawer">
|
||||||
<summary>Signal source</summary>
|
<summary>Signal</summary>
|
||||||
<div class="signalGrid">
|
<div class="signalGrid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<div class="label">Signal address</div>
|
<span>Address</span>
|
||||||
<input id="relayUrl" class="input" type="text" spellcheck="false" />
|
<input id="relayUrl" class="input" type="text" spellcheck="false" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="checkField">
|
||||||
<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" />
|
<input id="archiveMode" type="checkbox" />
|
||||||
<span>DVR replay</span>
|
<span>DVR replay</span>
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
<button id="watchBtn" class="btn">Tune in</button>
|
<button id="copyLinkBtn" class="secondaryButton" type="button">Copy link</button>
|
||||||
</div>
|
<div id="shareLink" class="shareLink" aria-live="polite"></div>
|
||||||
<div class="share">
|
|
||||||
<button id="copyLinkBtn" class="btn secondary">Copy link</button>
|
|
||||||
<div class="shareLink" id="shareLink" aria-live="polite"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="foot">
|
|
||||||
<div>AGPLv3</div>
|
|
||||||
<div>Safari support is still experimental.</div>
|
|
||||||
</footer>
|
|
||||||
</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
|
|
@ -5116,21 +5116,34 @@ 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(),
|
||||||
|
track_name: track_name.clone(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
None
|
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)]
|
||||||
struct WebStreamUpsertReq<'a> {
|
struct WebStreamUpsertReq<'a> {
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
44
evolution/proposals/ECP-0120-material-watch-surface.md
Normal file
44
evolution/proposals/ECP-0120-material-watch-surface.md
Normal 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.
|
||||||
50
evolution/proposals/ECP-0121-greedy-multi-relay-streams.md
Normal file
50
evolution/proposals/ECP-0121-greedy-multi-relay-streams.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue