nixamp 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +225 -0
  2. package/bin/nixamp.mjs +4 -1
  3. package/dist/accounts.d.ts +54 -0
  4. package/dist/accounts.js +160 -0
  5. package/dist/admin.d.ts +47 -0
  6. package/dist/admin.js +209 -0
  7. package/dist/broadcast.d.ts +96 -0
  8. package/dist/broadcast.js +193 -0
  9. package/dist/channels.d.ts +94 -0
  10. package/dist/channels.js +235 -0
  11. package/dist/connections.d.ts +72 -0
  12. package/dist/connections.js +128 -0
  13. package/dist/daemon.d.ts +39 -0
  14. package/dist/daemon.js +170 -0
  15. package/dist/directory.d.ts +63 -0
  16. package/dist/directory.js +111 -0
  17. package/dist/ingest.d.ts +80 -0
  18. package/dist/ingest.js +252 -0
  19. package/dist/main.js +103 -6
  20. package/dist/manage.js +30 -7
  21. package/dist/owner.d.ts +53 -0
  22. package/dist/owner.js +96 -0
  23. package/dist/paywall.d.ts +60 -0
  24. package/dist/paywall.js +162 -0
  25. package/dist/playlist.d.ts +16 -0
  26. package/dist/playlist.js +57 -2
  27. package/dist/publish.d.ts +36 -0
  28. package/dist/publish.js +90 -0
  29. package/dist/rtmp-in.d.ts +22 -0
  30. package/dist/rtmp-in.js +79 -0
  31. package/dist/server.d.ts +117 -4
  32. package/dist/server.js +923 -24
  33. package/dist/session.d.ts +29 -0
  34. package/dist/session.js +184 -0
  35. package/dist/share.d.ts +74 -0
  36. package/dist/share.js +172 -0
  37. package/dist/sources.d.ts +37 -0
  38. package/dist/sources.js +125 -0
  39. package/package.json +5 -2
  40. package/src/accounts.ts +193 -0
  41. package/src/admin.ts +243 -0
  42. package/src/broadcast.ts +264 -0
  43. package/src/channels.ts +281 -0
  44. package/src/connections.ts +158 -0
  45. package/src/daemon.ts +193 -0
  46. package/src/directory.ts +135 -0
  47. package/src/ingest.ts +297 -0
  48. package/src/main.ts +107 -6
  49. package/src/manage.ts +35 -7
  50. package/src/owner.ts +113 -0
  51. package/src/paywall.ts +198 -0
  52. package/src/playlist.ts +68 -2
  53. package/src/publish.ts +101 -0
  54. package/src/rtmp-in.ts +90 -0
  55. package/src/server.ts +1087 -23
  56. package/src/session.ts +209 -0
  57. package/src/share.ts +193 -0
  58. package/src/sources.ts +136 -0
  59. package/src/types/auth-system.d.ts +77 -0
  60. package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
  61. package/web/dist/assets/index-WYJ6R4uF.js +1 -0
  62. package/web/dist/index.html +37 -6
  63. package/web/dist/install.ps1 +214 -0
  64. package/web/dist/sw.js +3 -3
  65. package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/dist/admin.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `nixamp admin` — what the daemon is doing, and who is listening to it.
3
+ *
4
+ * It talks to a running server over the same HTTP API a browser uses, so it
5
+ * works against the local daemon, against `nixamp serve` in another terminal,
6
+ * or against a nixamp on a different machine entirely.
7
+ */
8
+ import { createApp, themes } from "@profullstack/hqtui";
9
+ import { daemonUrl, readState } from "./daemon.js";
10
+ import { KEY_HEADER } from "./share.js";
11
+ /** Where to point, from the flags or from the daemon that is running. */
12
+ export function resolveTarget(argv) {
13
+ const at = argv.findIndex((a) => a === "--url" || a === "-u");
14
+ const keyAt = argv.findIndex((a) => a === "--key");
15
+ const url = at === -1 ? null : argv[at + 1];
16
+ const key = keyAt === -1 ? null : (argv[keyAt + 1] ?? null);
17
+ if (url)
18
+ return { url: url.replace(/\/+$/, ""), key };
19
+ const state = readState();
20
+ if (state === null) {
21
+ throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
22
+ }
23
+ return { url: daemonUrl(state), key: key ?? state.key };
24
+ }
25
+ /** Seconds as something a person reads at a glance. */
26
+ export function since(ms) {
27
+ const seconds = Math.max(0, Math.floor(ms / 1000));
28
+ if (seconds < 60)
29
+ return `${seconds}s`;
30
+ const minutes = Math.floor(seconds / 60);
31
+ if (minutes < 60)
32
+ return `${minutes}m ${seconds % 60}s`;
33
+ const hours = Math.floor(minutes / 60);
34
+ if (hours < 24)
35
+ return `${hours}h ${minutes % 60}m`;
36
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
37
+ }
38
+ export function bytes(value) {
39
+ const units = ["B", "KiB", "MiB", "GiB"];
40
+ let n = value;
41
+ for (const unit of units) {
42
+ if (n < 1024 || unit === "GiB")
43
+ return `${n < 10 && unit !== "B" ? n.toFixed(1) : Math.round(n)} ${unit}`;
44
+ n /= 1024;
45
+ }
46
+ return `${value} B`;
47
+ }
48
+ /** The colour a network deserves: the internet is the one worth noticing. */
49
+ function networkColor(theme, network) {
50
+ return network === "public"
51
+ ? theme.warning
52
+ : network === "cgnat"
53
+ ? theme.secondary
54
+ : network === "local"
55
+ ? theme.muted
56
+ : theme.success;
57
+ }
58
+ export async function admin(argv) {
59
+ const target = resolveTarget(argv);
60
+ const headers = target.key ? { [KEY_HEADER]: target.key } : {};
61
+ const ask = async (path) => {
62
+ try {
63
+ const response = await fetch(`${target.url}${path}`, { headers });
64
+ return response.ok ? (await response.json()) : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ };
70
+ let report = null;
71
+ let snapshot = null;
72
+ let error = "";
73
+ let restreaming = "";
74
+ let typing = false;
75
+ const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
76
+ const refresh = async () => {
77
+ const [next, state] = await Promise.all([ask("/api/connections"), ask("/api/state")]);
78
+ error = next === null ? `cannot reach ${target.url}` : "";
79
+ if (next)
80
+ report = next;
81
+ if (state)
82
+ snapshot = state;
83
+ app.invalidate();
84
+ };
85
+ const timer = setInterval(() => void refresh(), 1000);
86
+ await refresh();
87
+ app.on("key", (event) => {
88
+ const key = event.key;
89
+ if (typing) {
90
+ if (key === "escape") {
91
+ typing = false;
92
+ restreaming = "";
93
+ }
94
+ else if (key === "enter") {
95
+ const url = restreaming.trim();
96
+ typing = false;
97
+ restreaming = "";
98
+ if (url)
99
+ void restream(target, headers, url).then(() => refresh());
100
+ }
101
+ else if (key === "backspace")
102
+ restreaming = restreaming.slice(0, -1);
103
+ // A printable key is a character; everything else is a name like "f1".
104
+ else if (key.length === 1)
105
+ restreaming += key;
106
+ app.invalidate();
107
+ return;
108
+ }
109
+ if (key === "q") {
110
+ app.quit();
111
+ return;
112
+ }
113
+ if (key === "r") {
114
+ typing = true;
115
+ app.invalidate();
116
+ }
117
+ });
118
+ app.on("exit", () => clearInterval(timer));
119
+ app.render(({ ui, theme }) => draw(ui, theme, {
120
+ url: target.url, report, snapshot, error, typing, restreaming,
121
+ }));
122
+ await app.start();
123
+ clearInterval(timer);
124
+ }
125
+ /** Ask the server to play something else, which is what re-streaming is. */
126
+ async function restream(target, headers, url) {
127
+ try {
128
+ await fetch(`${target.url}/api/source`, {
129
+ method: "POST",
130
+ headers: { ...headers, "content-type": "application/json" },
131
+ body: JSON.stringify({ source: url }),
132
+ });
133
+ }
134
+ catch {
135
+ // The next refresh reports the server being unreachable; this is not the
136
+ // place to make that noise.
137
+ }
138
+ }
139
+ export function draw(ui, theme, view) {
140
+ const { report, snapshot } = view;
141
+ const now = report?.now ?? Date.now();
142
+ ui.row({ size: 7, gap: 1 }, (row) => {
143
+ row.panel({ title: "Server" }, (p) => {
144
+ p.text(view.url, { fg: theme.primary });
145
+ p.label(snapshot?.root ?? "—");
146
+ p.keyValues([
147
+ { label: "Uptime", value: report ? since(now - report.startedAt) : "—", color: theme.accent },
148
+ { label: "Tracks", value: String(snapshot?.tracks.length ?? 0), color: theme.foreground },
149
+ { label: "Listeners", value: String(report?.active ?? 0), color: theme.success },
150
+ ]);
151
+ });
152
+ row.panel({ title: "Now playing" }, (p) => {
153
+ const track = snapshot ? snapshot.tracks[snapshot.index] : undefined;
154
+ p.text(track?.title ?? "nothing", { fg: theme.accent });
155
+ p.label(track?.artist || "—");
156
+ p.keyValues([
157
+ { label: "State", value: snapshot?.playing ? "playing" : "stopped", color: snapshot?.playing ? theme.success : theme.muted },
158
+ { label: "Position", value: snapshot ? since(snapshot.position * 1000) : "—", color: theme.foreground },
159
+ ]);
160
+ });
161
+ });
162
+ ui.panel({ title: `Connections (${report?.active ?? 0} live)` }, (p) => {
163
+ if (view.error) {
164
+ p.text(view.error, { fg: theme.danger });
165
+ return;
166
+ }
167
+ const rows = report?.connections ?? [];
168
+ if (rows.length === 0) {
169
+ p.label("Nobody is listening yet.");
170
+ return;
171
+ }
172
+ // A finished connection is drawn in muted colours rather than dropped: the
173
+ // most useful thing an admin view can say is "it stopped ten seconds ago".
174
+ const dim = (row, live) => (row.endedAt === null ? live : theme.muted);
175
+ p.table({
176
+ rows,
177
+ header: true,
178
+ headerColor: theme.muted,
179
+ zebra: false,
180
+ columns: [
181
+ { key: "address", title: "Where", min: 12, color: (row) => dim(row, theme.foreground) },
182
+ { key: "network", title: "Network", width: 9, color: (row) => dim(row, networkColor(theme, row.network)) },
183
+ { key: "kind", title: "Kind", width: 7, color: theme.muted },
184
+ { key: "agent", title: "Client", width: 12, color: theme.muted },
185
+ { key: "track", title: "Track", min: 16, color: (row) => dim(row, theme.primary),
186
+ render: (row) => row.track || "—" },
187
+ { key: "for", title: "For", width: 10, align: "right", color: theme.muted,
188
+ render: (row) => (row.endedAt === null
189
+ ? since(now - row.startedAt)
190
+ : `${since(row.endedAt - row.startedAt)} ago`) },
191
+ { key: "bytes", title: "Sent", width: 9, align: "right",
192
+ color: (row) => dim(row, theme.success), render: (row) => bytes(row.bytes) },
193
+ ],
194
+ });
195
+ });
196
+ if (view.typing) {
197
+ ui.panel({ title: "Re-stream a URL or a path", size: 4 }, (p) => {
198
+ p.text(`${view.restreaming}_`, { fg: theme.accent });
199
+ p.label("Enter plays it here. Escape forgets it.");
200
+ });
201
+ }
202
+ ui.statusBar({
203
+ items: [
204
+ { key: "r", label: "Re-stream" },
205
+ { key: "q", label: "Quit" },
206
+ ],
207
+ right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
208
+ });
209
+ }
@@ -0,0 +1,96 @@
1
+ export interface Destination {
2
+ id: string;
3
+ /** What to call it: "YouTube", "X", the name of a server. */
4
+ name: string;
5
+ /** rtmp://a.rtmp.youtube.com/live2 — without the key. */
6
+ url: string;
7
+ /** The stream key. It never leaves the machine: see redact(). */
8
+ key: string;
9
+ enabled: boolean;
10
+ }
11
+ export interface EncoderSettings {
12
+ /** kbps. */
13
+ videoBitrate: number;
14
+ audioBitrate: number;
15
+ framerate: number;
16
+ /** Seconds between keyframes. One, unless you enjoy YouTube stalling. */
17
+ keyframeInterval: number;
18
+ resolution: "720p" | "1080p";
19
+ }
20
+ export declare const DEFAULT_ENCODER: EncoderSettings;
21
+ /** The RTMP ingest URLs of the places people actually go live. */
22
+ export declare const PRESETS: Record<string, string>;
23
+ export declare function resolutionOf(resolution: EncoderSettings["resolution"]): {
24
+ width: number;
25
+ height: number;
26
+ };
27
+ /** The full ingest URL. Built here so a key is never assembled by a client. */
28
+ export declare function ingestUrl(destination: Destination): string;
29
+ /** Somewhere to actually send RTMP. */
30
+ export declare function isRtmp(url: string): boolean;
31
+ /**
32
+ * A destination as it may be shown to anyone. A stream key is a password: it
33
+ * lets a stranger broadcast as you until you rotate it.
34
+ */
35
+ export declare function redact(destination: Destination): Omit<Destination, "key"> & {
36
+ key: string;
37
+ };
38
+ /**
39
+ * A tee output. `onfail=ignore` is the important part: without it one dead
40
+ * destination takes the whole broadcast down with it, and the one that dies is
41
+ * usually the one whose key expired without telling you.
42
+ */
43
+ export declare function teeOutput(url: string, options?: string[]): string;
44
+ export interface BroadcastPlan {
45
+ source: string;
46
+ destinations: Destination[];
47
+ settings: EncoderSettings;
48
+ /** Also produce web-playable audio on stdout, from the same decode. */
49
+ webAudio: boolean;
50
+ /** The source has no video track, so one has to be invented for RTMP. */
51
+ needsVideo: boolean;
52
+ }
53
+ /**
54
+ * The whole ffmpeg command.
55
+ *
56
+ * RTMP platforms want a video track even when what you are sending is music,
57
+ * so a silent source gets a flat colour at the chosen size. It is what a radio
58
+ * stream looks like on YouTube either way.
59
+ */
60
+ export declare function buildBroadcastArgs(plan: BroadcastPlan): string[];
61
+ export type BroadcastState = "idle" | "live" | "failed";
62
+ export interface BroadcastStatus {
63
+ state: BroadcastState;
64
+ since: number | null;
65
+ /** Names only, and never a key. */
66
+ destinations: string[];
67
+ error: string;
68
+ }
69
+ /**
70
+ * One broadcast at a time, restarted when it dies. A live stream that stops
71
+ * because a platform hiccupped, and stays stopped, is worse than no feature.
72
+ */
73
+ export declare class Broadcaster {
74
+ private readonly ffmpeg;
75
+ /** Injected so a test never waits five real seconds. */
76
+ private readonly delay;
77
+ private child;
78
+ private plan;
79
+ private timer;
80
+ private attempts;
81
+ private state;
82
+ private since;
83
+ private error;
84
+ constructor(ffmpeg?: string[],
85
+ /** Injected so a test never waits five real seconds. */
86
+ delay?: (ms: number, run: () => void) => NodeJS.Timeout);
87
+ status(): BroadcastStatus;
88
+ start(plan: BroadcastPlan): {
89
+ ok: boolean;
90
+ error: string;
91
+ };
92
+ stop(): void;
93
+ private spawn;
94
+ /** Back off, but never give up entirely while a plan is set. */
95
+ private retry;
96
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Broadcasting out to RTMP, to as many places at once as you like.
3
+ *
4
+ * One ffmpeg, one encode, many outputs, through the `tee` muxer. Running an
5
+ * ffmpeg per destination is the obvious shape and it encodes the same frames
6
+ * four times; tee encodes once and writes the result to every URL.
7
+ *
8
+ * The encoder settings are PairUX's, which learned them the hard way against
9
+ * the real platforms: a one-second keyframe interval because YouTube stalls on
10
+ * ffmpeg's default, a forced constant frame rate because a variable-rate source
11
+ * makes YouTube report "not receiving enough video", and yuv420p because that
12
+ * is what RTMP platforms accept.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ export const DEFAULT_ENCODER = {
16
+ videoBitrate: 4500,
17
+ audioBitrate: 128,
18
+ framerate: 30,
19
+ keyframeInterval: 1,
20
+ resolution: "1080p",
21
+ };
22
+ /** The RTMP ingest URLs of the places people actually go live. */
23
+ export const PRESETS = {
24
+ youtube: "rtmp://a.rtmp.youtube.com/live2",
25
+ x: "rtmp://ingest.x.com:1935/live",
26
+ facebook: "rtmps://live-api-s.facebook.com:443/rtmp",
27
+ tiktok: "rtmp://push.tiktokcdn.com/live",
28
+ twitch: "rtmp://live.twitch.tv/app",
29
+ kick: "rtmps://fa723fc1b171.global-contribute.live-video.net:443/app",
30
+ };
31
+ export function resolutionOf(resolution) {
32
+ return resolution === "720p" ? { width: 1280, height: 720 } : { width: 1920, height: 1080 };
33
+ }
34
+ /** The full ingest URL. Built here so a key is never assembled by a client. */
35
+ export function ingestUrl(destination) {
36
+ return `${destination.url.replace(/\/+$/, "")}/${destination.key}`;
37
+ }
38
+ /** Somewhere to actually send RTMP. */
39
+ export function isRtmp(url) {
40
+ return /^rtmps?:\/\/[^\s/]+/i.test(url);
41
+ }
42
+ /**
43
+ * A destination as it may be shown to anyone. A stream key is a password: it
44
+ * lets a stranger broadcast as you until you rotate it.
45
+ */
46
+ export function redact(destination) {
47
+ const tail = destination.key.slice(-4);
48
+ return { ...destination, key: destination.key ? `••••${tail}` : "" };
49
+ }
50
+ /**
51
+ * A tee output. `onfail=ignore` is the important part: without it one dead
52
+ * destination takes the whole broadcast down with it, and the one that dies is
53
+ * usually the one whose key expired without telling you.
54
+ */
55
+ export function teeOutput(url, options = ["f=flv"]) {
56
+ return `[${[...options, "onfail=ignore"].join(":")}]${url}`;
57
+ }
58
+ /**
59
+ * The whole ffmpeg command.
60
+ *
61
+ * RTMP platforms want a video track even when what you are sending is music,
62
+ * so a silent source gets a flat colour at the chosen size. It is what a radio
63
+ * stream looks like on YouTube either way.
64
+ */
65
+ export function buildBroadcastArgs(plan) {
66
+ const { width, height } = resolutionOf(plan.settings.resolution);
67
+ const gop = plan.settings.framerate * plan.settings.keyframeInterval;
68
+ const args = ["-hide_banner", "-loglevel", "error"];
69
+ // -re only for a file: a live source already arrives in real time, and
70
+ // throttling it a second time drifts further behind with every track.
71
+ if (!/^(https?|rtmps?|pipe):/i.test(plan.source) && plan.source !== "pipe:0")
72
+ args.push("-re");
73
+ if (plan.needsVideo) {
74
+ args.push("-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=${plan.settings.framerate}`);
75
+ }
76
+ args.push("-i", plan.source);
77
+ // Video is always input 0: either the invented colour, or the source's own.
78
+ // Audio moves to input 1 when a colour was pushed in front of it.
79
+ args.push("-map", "0:v", "-map", plan.needsVideo ? "1:a" : "0:a");
80
+ args.push("-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-b:v", `${plan.settings.videoBitrate}k`, "-maxrate", `${Math.round(plan.settings.videoBitrate * 1.1)}k`, "-bufsize", `${plan.settings.videoBitrate * 2}k`,
81
+ // A strict constant frame rate. A source that only produces frames when
82
+ // something changes reads to YouTube as a stream that is falling behind.
83
+ "-vf", `scale=${width}:${height},fps=${plan.settings.framerate}`, "-pix_fmt", "yuv420p", "-g", String(gop), "-c:a", "aac", "-b:a", `${plan.settings.audioBitrate}k`, "-ar", "44100");
84
+ const outputs = plan.destinations
85
+ .filter((d) => d.enabled && isRtmp(d.url))
86
+ .map((d) => teeOutput(ingestUrl(d)));
87
+ // The web copy rides along on the same encode, audio only, down stdout.
88
+ if (plan.webAudio)
89
+ outputs.push(teeOutput("pipe:1", ["select=a", "f=mp3"]));
90
+ if (outputs.length === 0)
91
+ return [];
92
+ // One output does not need the tee muxer, and ffmpeg reports its errors more
93
+ // clearly without it.
94
+ if (outputs.length === 1 && !plan.webAudio) {
95
+ const only = plan.destinations.find((d) => d.enabled && isRtmp(d.url));
96
+ args.push("-f", "flv", ingestUrl(only));
97
+ return args;
98
+ }
99
+ args.push("-flags", "+global_header", "-f", "tee", outputs.join("|"));
100
+ return args;
101
+ }
102
+ /**
103
+ * One broadcast at a time, restarted when it dies. A live stream that stops
104
+ * because a platform hiccupped, and stays stopped, is worse than no feature.
105
+ */
106
+ export class Broadcaster {
107
+ ffmpeg;
108
+ delay;
109
+ child = null;
110
+ plan = null;
111
+ timer = null;
112
+ attempts = 0;
113
+ state = "idle";
114
+ since = null;
115
+ error = "";
116
+ constructor(ffmpeg = ["ffmpeg"],
117
+ /** Injected so a test never waits five real seconds. */
118
+ delay = (ms, run) => setTimeout(run, ms)) {
119
+ this.ffmpeg = ffmpeg;
120
+ this.delay = delay;
121
+ }
122
+ status() {
123
+ return {
124
+ state: this.state,
125
+ since: this.since,
126
+ destinations: (this.plan?.destinations ?? []).filter((d) => d.enabled).map((d) => d.name),
127
+ error: this.error,
128
+ };
129
+ }
130
+ start(plan) {
131
+ const args = buildBroadcastArgs(plan);
132
+ if (args.length === 0)
133
+ return { ok: false, error: "no enabled destination with an rtmp url" };
134
+ this.stop();
135
+ this.plan = plan;
136
+ this.attempts = 0;
137
+ this.error = "";
138
+ this.spawn(args);
139
+ return { ok: true, error: "" };
140
+ }
141
+ stop() {
142
+ if (this.timer)
143
+ clearTimeout(this.timer);
144
+ this.timer = null;
145
+ this.plan = null;
146
+ this.state = "idle";
147
+ this.since = null;
148
+ const child = this.child;
149
+ this.child = null;
150
+ child?.kill("SIGKILL");
151
+ }
152
+ spawn(args) {
153
+ const [command, ...prefix] = this.ffmpeg;
154
+ const child = spawn(command, [...prefix, ...args], { stdio: ["ignore", "pipe", "pipe"] });
155
+ this.child = child;
156
+ this.state = "live";
157
+ this.since = Date.now();
158
+ let tail = "";
159
+ child.stderr?.on("data", (chunk) => {
160
+ tail = (tail + chunk.toString()).slice(-2000);
161
+ });
162
+ child.on("error", (error) => {
163
+ this.error = error.message;
164
+ this.state = "failed";
165
+ });
166
+ child.on("close", (code) => {
167
+ if (this.child !== child)
168
+ return; // stopped on purpose, or replaced
169
+ this.child = null;
170
+ if (code === 0) {
171
+ this.state = "idle";
172
+ this.since = null;
173
+ return;
174
+ }
175
+ this.error = tail.trim().split("\n").pop() ?? `ffmpeg exited ${code}`;
176
+ this.state = "failed";
177
+ this.retry();
178
+ });
179
+ }
180
+ /** Back off, but never give up entirely while a plan is set. */
181
+ retry() {
182
+ const plan = this.plan;
183
+ if (plan === null)
184
+ return;
185
+ this.attempts++;
186
+ const wait = Math.min(30_000, 1000 * 2 ** Math.min(5, this.attempts - 1));
187
+ this.timer = this.delay(wait, () => {
188
+ if (this.plan !== plan)
189
+ return;
190
+ this.spawn(buildBroadcastArgs(plan));
191
+ });
192
+ }
193
+ }
@@ -0,0 +1,94 @@
1
+ import type { Readable } from "node:stream";
2
+ /** Somewhere for a channel's audio to go. A response, in practice. */
3
+ export interface Listener {
4
+ write(chunk: Buffer): boolean;
5
+ end(): void;
6
+ }
7
+ export interface ChannelInfo {
8
+ id: string;
9
+ /** What the publisher called itself. */
10
+ name: string;
11
+ /** The container it is sending, e.g. webm from a browser, flv over RTMP. */
12
+ format: string;
13
+ /** How it arrived. */
14
+ via: "http" | "rtmp";
15
+ startedAt: number;
16
+ bytes: number;
17
+ listeners: number;
18
+ }
19
+ /** A name that can sit in a URL and be read back in a list. */
20
+ export declare function cleanId(value: unknown, fallback?: string): string;
21
+ export interface ChannelOptions {
22
+ ffmpeg: string[];
23
+ onStart?: (info: ChannelInfo) => void;
24
+ onEnd?: (info: ChannelInfo) => void;
25
+ }
26
+ /**
27
+ * One live source, and its audience.
28
+ *
29
+ * Everything a listener is sent has been through ffmpeg, so a publisher cannot
30
+ * decide what bytes reach a browser by choosing what to send.
31
+ */
32
+ export declare class Channel {
33
+ readonly info: ChannelInfo;
34
+ private readonly options;
35
+ private readonly onGone;
36
+ readonly listeners: Set<Listener>;
37
+ private child;
38
+ private closing;
39
+ constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
40
+ start(format: string): void;
41
+ /** Feed the source. */
42
+ write(chunk: Buffer): boolean;
43
+ pump(body: Readable): Promise<void>;
44
+ /**
45
+ * Audio that is already in its final form, from a source we did not spawn.
46
+ * The bytes still only reach a listener after something decoded them; it was
47
+ * simply a different process that did it.
48
+ */
49
+ feed(chunk: Buffer): void;
50
+ /** Write to everyone, and drop anybody whose socket has gone. */
51
+ private send;
52
+ listen(listener: Listener): () => void;
53
+ close(): void;
54
+ }
55
+ /**
56
+ * Every channel currently live.
57
+ *
58
+ * A channel exists while somebody is publishing to it and disappears when they
59
+ * stop, so the list is what is actually on rather than what was once
60
+ * configured.
61
+ */
62
+ export declare class Channels {
63
+ private readonly options;
64
+ private readonly open;
65
+ constructor(options: ChannelOptions);
66
+ list(): ChannelInfo[];
67
+ get count(): number;
68
+ /** Total listeners across every channel. */
69
+ get listeners(): number;
70
+ has(id: string): boolean;
71
+ /**
72
+ * Claim a channel and start decoding into it. Null when that channel is
73
+ * already being published to: two publishers on one channel would be two
74
+ * songs at once, which is never what anybody meant. Publishing to a
75
+ * *different* channel is exactly what this class exists for.
76
+ */
77
+ publish(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null;
78
+ /** Attach a listener, or null when nothing is playing on that channel. */
79
+ listen(id: string, listener: Listener): (() => void) | null;
80
+ /** Feed a channel that already exists, for a publisher sending chunks. */
81
+ writeTo(id: string, chunk: Buffer): boolean;
82
+ /**
83
+ * A channel fed by audio somebody else is already decoding.
84
+ *
85
+ * An RTMP listener is an ffmpeg with a publisher on one end, and it produces
86
+ * MP3 on its own. Spawning a second ffmpeg to decode what the first one just
87
+ * decoded would double the work to arrive at the same bytes.
88
+ */
89
+ attach(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null;
90
+ stop(id: string): boolean;
91
+ stopAll(): void;
92
+ }
93
+ /** A channel id nobody chose, for a publisher that did not name one. */
94
+ export declare function generatedId(): string;