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();
|
||||
}
|
||||
|
||||
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) {
|
||||
const u = new URL(window.location.href);
|
||||
u.pathname = "/watch";
|
||||
|
|
@ -59,6 +94,11 @@ function setHint(text, kind) {
|
|||
el.dataset.kind = kind || "";
|
||||
}
|
||||
|
||||
function setNowTitle(text) {
|
||||
const el = document.getElementById("nowTitle");
|
||||
if (el) el.textContent = text || "Ready";
|
||||
}
|
||||
|
||||
function setShareLink(text) {
|
||||
const el = $("shareLink");
|
||||
el.textContent = text || "";
|
||||
|
|
@ -70,6 +110,84 @@ function setListHint(text, 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() {
|
||||
if (typeof disposePlayerSignals === "function") {
|
||||
disposePlayerSignals();
|
||||
|
|
@ -110,11 +228,13 @@ function bindPlayerSignals(watch, name, extraCleanup) {
|
|||
if (status === "loading") {
|
||||
clearOfflineTimer();
|
||||
sawLoading = true;
|
||||
setNowTitle(name);
|
||||
setHint(`Tuning ${name}...`, "ok");
|
||||
return;
|
||||
}
|
||||
if (status === "live") {
|
||||
clearOfflineTimer();
|
||||
setNowTitle(name);
|
||||
setHint(`On air: ${name}`, "ok");
|
||||
return;
|
||||
}
|
||||
|
|
@ -133,6 +253,7 @@ function bindPlayerSignals(watch, name, extraCleanup) {
|
|||
const hasVideo = Boolean(catalog.video && catalog.video.renditions);
|
||||
const hasAudio = Boolean(catalog.audio && catalog.audio.renditions);
|
||||
if (hasVideo || hasAudio) {
|
||||
setNowTitle(name);
|
||||
setHint(`On air: ${name}`, "ok");
|
||||
}
|
||||
});
|
||||
|
|
@ -157,6 +278,7 @@ function mountPlayer(relayUrl, name) {
|
|||
|
||||
const mount = $("playerMount");
|
||||
mount.textContent = "";
|
||||
setNowTitle(name);
|
||||
|
||||
const watch = document.createElement("moq-watch");
|
||||
watch.setAttribute("url", relayUrl);
|
||||
|
|
@ -276,6 +398,7 @@ async function mountArchivePlayer(name) {
|
|||
|
||||
const mount = $("playerMount");
|
||||
mount.textContent = "";
|
||||
setNowTitle(name);
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.className = "archiveVideo";
|
||||
|
|
@ -393,48 +516,60 @@ function renderLiveList(entries, onWatchLive, onWatchArchive) {
|
|||
mount.textContent = "";
|
||||
if (!entries.length) {
|
||||
setListHint("No stations are on air yet.", "");
|
||||
renderMultiview([], onWatchLive);
|
||||
renderManualTunePrompt("No channels found");
|
||||
return;
|
||||
}
|
||||
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");
|
||||
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 title = document.createElement("div");
|
||||
title.className = "liveTitle";
|
||||
title.textContent = entry.title || entry.stream_id || entry.broadcast_name || "Live stream";
|
||||
title.textContent = titleText;
|
||||
info.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "liveMeta";
|
||||
meta.textContent = entry.broadcast_name
|
||||
? `Channel key: ${entry.broadcast_name}`
|
||||
: "Ready to tune";
|
||||
const relayCount = entryRelays(entry).length;
|
||||
const relayText = relayCount > 1 ? ` · ${relayCount} relays` : "";
|
||||
meta.textContent = `${entry.broadcast_name || "Ready"}${relayText}`;
|
||||
info.appendChild(meta);
|
||||
|
||||
const watchBadge = document.createElement("span");
|
||||
watchBadge.className = "watchBadge";
|
||||
watchBadge.textContent = "Watch";
|
||||
|
||||
const actions = document.createElement("div");
|
||||
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");
|
||||
archiveBtn.className = "btn secondary";
|
||||
archiveBtn.className = "secondaryButton";
|
||||
archiveBtn.type = "button";
|
||||
archiveBtn.textContent = "DVR";
|
||||
archiveBtn.addEventListener("click", () => {
|
||||
onWatchArchive(entry);
|
||||
});
|
||||
|
||||
actions.appendChild(watchBtn);
|
||||
actions.appendChild(archiveBtn);
|
||||
|
||||
row.appendChild(info);
|
||||
tuneButton.appendChild(info);
|
||||
tuneButton.appendChild(watchBadge);
|
||||
row.appendChild(tuneButton);
|
||||
row.appendChild(actions);
|
||||
mount.appendChild(row);
|
||||
}
|
||||
|
|
@ -457,11 +592,30 @@ function main() {
|
|||
const watchBtn = $("watchBtn");
|
||||
const copyBtn = $("copyLinkBtn");
|
||||
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();
|
||||
relayInput.value = initial.relayUrl;
|
||||
nameInput.value = initial.name;
|
||||
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() {
|
||||
const relayUrl = normalizeRelayUrl(relayInput.value);
|
||||
|
|
@ -472,6 +626,20 @@ function main() {
|
|||
return;
|
||||
}
|
||||
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() {
|
||||
|
|
@ -483,11 +651,14 @@ function main() {
|
|||
|
||||
if (!name) {
|
||||
setHint("Pick a station or enter a channel key.", "warn");
|
||||
setNowTitle("Ready");
|
||||
openSignalDrawer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "archive") {
|
||||
writeParams(relayUrl, name, mode);
|
||||
setNowTitle(name);
|
||||
setHint(`Loading replay: ${name}`, "ok");
|
||||
try {
|
||||
await mountArchivePlayer(name);
|
||||
|
|
@ -502,7 +673,7 @@ function main() {
|
|||
|
||||
if (!hasWebTransport()) {
|
||||
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",
|
||||
);
|
||||
return;
|
||||
|
|
@ -519,6 +690,7 @@ function main() {
|
|||
}
|
||||
|
||||
writeParams(relayUrl, name, mode);
|
||||
setNowTitle(name);
|
||||
setHint(`Tuning ${name}...`, "ok");
|
||||
mountPlayer(relayUrl, name);
|
||||
}
|
||||
|
|
@ -526,6 +698,53 @@ function main() {
|
|||
relayInput.addEventListener("input", updateSharePreview);
|
||||
nameInput.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", () => {
|
||||
void start();
|
||||
|
|
@ -560,23 +779,27 @@ function main() {
|
|||
renderLiveList(
|
||||
entries,
|
||||
(entry) => {
|
||||
const relay = entryPrimaryRelay(entry);
|
||||
archiveModeInput.checked = false;
|
||||
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
|
||||
nameInput.value = normalizeName(entry.broadcast_name || "");
|
||||
relayInput.value = relay.relay_url;
|
||||
nameInput.value = relay.broadcast_name;
|
||||
updateSharePreview();
|
||||
void start();
|
||||
},
|
||||
(entry) => {
|
||||
const relay = entryPrimaryRelay(entry);
|
||||
archiveModeInput.checked = true;
|
||||
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
|
||||
nameInput.value = normalizeName(entry.broadcast_name || "");
|
||||
relayInput.value = relay.relay_url;
|
||||
nameInput.value = relay.broadcast_name;
|
||||
updateSharePreview();
|
||||
void start();
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
$("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", () => {
|
||||
|
|
@ -584,6 +807,8 @@ function main() {
|
|||
});
|
||||
|
||||
updateSharePreview();
|
||||
updateModeButtons();
|
||||
renderDefaultMultiview();
|
||||
void refreshLiveList();
|
||||
window.setInterval(() => {
|
||||
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 name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>every.channel</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Watch and share free over-the-air TV. Local first, global when you want."
|
||||
/>
|
||||
<meta name="description" content="Watch live television from every.channel." />
|
||||
<link data-trunk rel="css" href="style.css" />
|
||||
<link data-trunk rel="copy-file" href="app.js" />
|
||||
<link data-trunk rel="copy-dir" href="assets" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="studio">
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<div class="brand-title">every.channel</div>
|
||||
<div class="brand-subtitle">Live television, tuned from real signals.</div>
|
||||
</div>
|
||||
<div class="badge">Local broadcast</div>
|
||||
<main class="watchSurface">
|
||||
<header class="topbar" aria-label="Watch controls">
|
||||
<a class="brand" href="/" aria-label="every.channel">
|
||||
<span class="brandLens" aria-hidden="true"></span>
|
||||
<span>every.channel</span>
|
||||
</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="watchDeck">
|
||||
<section class="player" aria-label="Live player">
|
||||
<div class="tv">
|
||||
<div class="tv-frame">
|
||||
<div id="playerMount" class="mount"></div>
|
||||
<div class="tv-scanlines" aria-hidden="true"></div>
|
||||
<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 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>
|
||||
|
||||
<aside class="console" aria-label="Channel console">
|
||||
<div class="consoleHead">
|
||||
<aside class="channelRail" aria-label="Channels">
|
||||
<div class="railHead">
|
||||
<div>
|
||||
<div class="panel-title">On Air</div>
|
||||
<div class="consoleCopy">Pick a station and the picture starts here.</div>
|
||||
<div class="kicker">Channels</div>
|
||||
<div id="listHint" class="listHint" aria-live="polite"></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 class="hint listHint" id="listHint"></div>
|
||||
</div>
|
||||
|
||||
<div id="liveList" class="liveList"></div>
|
||||
|
||||
<details class="signalDrawer">
|
||||
<summary>Signal source</summary>
|
||||
<details id="signalDrawer" class="signalDrawer">
|
||||
<summary>Signal</summary>
|
||||
<div class="signalGrid">
|
||||
<label class="field">
|
||||
<div class="label">Signal address</div>
|
||||
<span>Address</span>
|
||||
<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">
|
||||
<label class="checkField">
|
||||
<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>
|
||||
<button id="copyLinkBtn" class="secondaryButton" type="button">Copy link</button>
|
||||
<div id="shareLink" class="shareLink" aria-live="polite"></div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<div>AGPLv3</div>
|
||||
<div>Safari support is still experimental.</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5116,20 +5116,33 @@ async fn control_resolve(args: ControlResolveArgs) -> Result<()> {
|
|||
))
|
||||
}
|
||||
|
||||
fn select_relay_transport_for_web(
|
||||
transports: &[StreamTransportDescriptor],
|
||||
) -> Option<(String, String, String)> {
|
||||
for transport in transports {
|
||||
fn relay_transports_for_web(transports: &[StreamTransportDescriptor]) -> Vec<WebStreamRelay> {
|
||||
transports
|
||||
.iter()
|
||||
.filter_map(|transport| {
|
||||
if let StreamTransportDescriptor::RelayMoq {
|
||||
url,
|
||||
broadcast_name,
|
||||
track_name,
|
||||
} = 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
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct WebStreamRelay {
|
||||
relay_url: String,
|
||||
broadcast_name: String,
|
||||
track_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
|
|
@ -5139,6 +5152,7 @@ struct WebStreamUpsertReq<'a> {
|
|||
relay_url: &'a str,
|
||||
broadcast_name: &'a str,
|
||||
track_name: &'a str,
|
||||
relays: &'a [WebStreamRelay],
|
||||
expires_ms: u64,
|
||||
}
|
||||
|
||||
|
|
@ -5207,11 +5221,11 @@ async fn control_bridge_web(args: ControlBridgeWebArgs) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
let Some((relay_url, broadcast_name, track_name)) =
|
||||
select_relay_transport_for_web(&announcement.transports)
|
||||
else {
|
||||
let relays = relay_transports_for_web(&announcement.transports);
|
||||
if relays.is_empty() {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
let primary_relay = &relays[0];
|
||||
|
||||
if last_upserted_unix_ms
|
||||
.get(&stream_id)
|
||||
|
|
@ -5224,9 +5238,10 @@ async fn control_bridge_web(args: ControlBridgeWebArgs) -> Result<()> {
|
|||
let payload = WebStreamUpsertReq {
|
||||
stream_id: &stream_id,
|
||||
title: &announcement.stream.title,
|
||||
relay_url: &relay_url,
|
||||
broadcast_name: &broadcast_name,
|
||||
track_name: &track_name,
|
||||
relay_url: &primary_relay.relay_url,
|
||||
broadcast_name: &primary_relay.broadcast_name,
|
||||
track_name: &primary_relay.track_name,
|
||||
relays: &relays,
|
||||
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);
|
||||
tracing::info!(
|
||||
stream = %stream_id,
|
||||
relay = %relay_url,
|
||||
broadcast = %broadcast_name,
|
||||
relay = %primary_relay.relay_url,
|
||||
broadcast = %primary_relay.broadcast_name,
|
||||
relay_count = relays.len(),
|
||||
"web stream upserted"
|
||||
);
|
||||
if args.once {
|
||||
|
|
@ -6853,6 +6869,7 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> {
|
|||
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::inherit());
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
tracing::info!(input=%args.input, "spawning 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.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::inherit());
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
tracing::info!(
|
||||
source_url = %args.source_url,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,31 @@ struct DirectoryList {
|
|||
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)]
|
||||
struct HealthResp {
|
||||
ok: bool,
|
||||
|
|
@ -69,10 +94,29 @@ struct AnswerGetReq {
|
|||
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)]
|
||||
struct State {
|
||||
entries: HashMap<String, DirectoryEntry>,
|
||||
answers: HashMap<String, AnswerEntry>,
|
||||
streams: HashMap<String, PublicStreamEntry>,
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
|
|
@ -100,6 +144,7 @@ fn json_headers() -> HeaderMap {
|
|||
fn prune_state(state: &mut State, now: u64) {
|
||||
state.entries.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.
|
||||
if state.entries.len() > 200 {
|
||||
|
|
@ -114,6 +159,12 @@ fn prune_state(state: &mut State, now: u64) {
|
|||
items.truncate(500);
|
||||
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 {
|
||||
|
|
@ -129,6 +180,118 @@ async fn directory(state: axum::extract::State<Arc<RwLock<State>>>) -> impl Into
|
|||
(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(
|
||||
state: axum::extract::State<Arc<RwLock<State>>>,
|
||||
Json(body): Json<AnnounceReq>,
|
||||
|
|
@ -233,6 +396,8 @@ async fn main() -> anyhow::Result<()> {
|
|||
let app = Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.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/answer", post(post_answer).get(get_answer))
|
||||
.with_state(state)
|
||||
|
|
|
|||
|
|
@ -212,10 +212,17 @@ type PublicStreamEntry = {
|
|||
relay_url: string;
|
||||
broadcast_name: string;
|
||||
track_name: string;
|
||||
relays: PublicStreamRelay[];
|
||||
updated_ms: number;
|
||||
expires_ms: number;
|
||||
};
|
||||
|
||||
type PublicStreamRelay = {
|
||||
relay_url: string;
|
||||
broadcast_name: string;
|
||||
track_name: string;
|
||||
};
|
||||
|
||||
type PublicStreamList = {
|
||||
now_ms: number;
|
||||
entries: PublicStreamEntry[];
|
||||
|
|
@ -323,9 +330,41 @@ type StreamUpsertReq = {
|
|||
relay_url: string;
|
||||
broadcast_name: string;
|
||||
track_name?: string;
|
||||
relays?: PublicStreamRelay[];
|
||||
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 {
|
||||
const auth = request.headers.get("authorization");
|
||||
if (!auth) return null;
|
||||
|
|
@ -392,17 +431,20 @@ export class EcApiContainer implements DurableObject {
|
|||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const relays = normalizePublicStreamRelays(body);
|
||||
|
||||
const requestedExpires = body.expires_ms ?? now + 20_000;
|
||||
const requestedTtl = Math.max(0, requestedExpires - now);
|
||||
const ttlMs = Math.min(60_000, Math.max(5_000, requestedTtl));
|
||||
|
||||
const primaryRelay = relays[0];
|
||||
const entry: PublicStreamEntry = {
|
||||
stream_id: clampStr(body.stream_id, 256),
|
||||
title: clampStr(body.title, 128),
|
||||
relay_url: clampStr(body.relay_url, 512),
|
||||
broadcast_name: clampStr(body.broadcast_name, 256),
|
||||
track_name: clampStr(body.track_name || "video0.m4s", 256),
|
||||
relay_url: primaryRelay.relay_url,
|
||||
broadcast_name: primaryRelay.broadcast_name,
|
||||
track_name: primaryRelay.track_name,
|
||||
relays,
|
||||
updated_ms: now,
|
||||
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 =
|
||||
[
|
||||
"d /run/every-channel 1777 root root - -"
|
||||
"d /run/every-channel/source-locks 1777 root root - -"
|
||||
]
|
||||
++ lib.optionals cfg.nbc.enable [
|
||||
"d /var/lib/every-channel 0750 every-channel every-channel - -"
|
||||
|
|
@ -487,6 +488,7 @@ in
|
|||
pkgs.findutils
|
||||
pkgs.gawk
|
||||
pkgs.iproute2
|
||||
pkgs.util-linux
|
||||
cfg.package
|
||||
]
|
||||
++ lib.optionals (isNbc && cfg.nbc.requireMullvad) [ pkgs.mullvad-vpn ]
|
||||
|
|
@ -580,8 +582,36 @@ in
|
|||
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}
|
||||
input=""
|
||||
source_id=""
|
||||
source_lock=""
|
||||
if [[ -z "$nbc_url" ]]; then
|
||||
explicit_input=${lib.escapeShellArg explicitInputStr}
|
||||
if [[ -n "$explicit_input" ]]; then
|
||||
|
|
@ -676,9 +706,11 @@ in
|
|||
host="''${hostport%%:*}"
|
||||
input="http://$host:5004/auto/v$ch"
|
||||
fi
|
||||
source_id="$input"
|
||||
fi
|
||||
|
||||
if [[ -n "$nbc_url" ]]; then
|
||||
source_id="$nbc_url"
|
||||
cmd=(
|
||||
${lib.escapeShellArg "${cfg.package}/bin/ec-node"}
|
||||
nbc-wt-publish
|
||||
|
|
@ -715,6 +747,11 @@ in
|
|||
''}
|
||||
${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.
|
||||
# This avoids `switch-to-configuration test` failing due to a unit that exits
|
||||
# quickly during activation.
|
||||
|
|
@ -726,9 +763,9 @@ in
|
|||
continue
|
||||
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) ''
|
||||
"''${cmd[@]}" || true
|
||||
run_source_command "''${cmd[@]}" || true
|
||||
''}
|
||||
sleep 2
|
||||
done
|
||||
|
|
@ -763,6 +800,9 @@ in
|
|||
ExecStart = "${runner}/bin/${unit}";
|
||||
Restart = "always";
|
||||
RestartSec = 2;
|
||||
KillMode = "control-group";
|
||||
TimeoutStopSec = "10s";
|
||||
SendSIGKILL = true;
|
||||
|
||||
DynamicUser = !isNbc;
|
||||
User = lib.mkIf isNbc "every-channel";
|
||||
|
|
@ -949,14 +989,24 @@ in
|
|||
poll_secs="$(awk 'BEGIN { printf "%.3f", ${toString cfg.archive.pollIntervalMs} / 1000.0 }')"
|
||||
|
||||
cleanup_children() {
|
||||
pids=()
|
||||
for pid_file in "$pids_dir"/*.pid; do
|
||||
[[ -e "$pid_file" ]] || continue
|
||||
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
||||
if [[ -n "$pid" ]]; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
pids+=("$pid")
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
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
|
||||
|
|
@ -970,7 +1020,7 @@ in
|
|||
|
||||
while IFS= read -r entry; do
|
||||
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
|
||||
continue
|
||||
fi
|
||||
|
|
@ -1039,6 +1089,9 @@ in
|
|||
ExecStart = "${archiveRunner}/bin/${archiveUnit}";
|
||||
Restart = "always";
|
||||
RestartSec = 2;
|
||||
KillMode = "control-group";
|
||||
TimeoutStopSec = "10s";
|
||||
SendSIGKILL = true;
|
||||
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue