nixamp 0.2.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.
- package/README.md +171 -0
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -0
- package/dist/broadcast.d.ts +96 -0
- package/dist/broadcast.js +193 -0
- package/dist/channels.d.ts +94 -0
- package/dist/channels.js +235 -0
- package/dist/connections.d.ts +6 -0
- package/dist/connections.js +13 -0
- package/dist/directory.d.ts +63 -0
- package/dist/directory.js +111 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +21 -0
- package/dist/manage.js +2 -1
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/publish.d.ts +36 -0
- package/dist/publish.js +90 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +79 -0
- package/dist/server.js +609 -10
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +16 -0
- package/dist/share.js +19 -0
- package/package.json +5 -2
- package/src/accounts.ts +193 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +13 -0
- package/src/directory.ts +135 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/owner.ts +113 -0
- package/src/paywall.ts +198 -0
- package/src/publish.ts +101 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +702 -10
- package/src/session.ts +209 -0
- package/src/share.ts +27 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
- package/web/dist/assets/index-WYJ6R4uF.js +1 -0
- package/web/dist/index.html +37 -6
- package/web/dist/sw.js +3 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/src/broadcast.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
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, type ChildProcess } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
export interface Destination {
|
|
17
|
+
id: string;
|
|
18
|
+
/** What to call it: "YouTube", "X", the name of a server. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** rtmp://a.rtmp.youtube.com/live2 — without the key. */
|
|
21
|
+
url: string;
|
|
22
|
+
/** The stream key. It never leaves the machine: see redact(). */
|
|
23
|
+
key: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EncoderSettings {
|
|
28
|
+
/** kbps. */
|
|
29
|
+
videoBitrate: number;
|
|
30
|
+
audioBitrate: number;
|
|
31
|
+
framerate: number;
|
|
32
|
+
/** Seconds between keyframes. One, unless you enjoy YouTube stalling. */
|
|
33
|
+
keyframeInterval: number;
|
|
34
|
+
resolution: "720p" | "1080p";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_ENCODER: EncoderSettings = {
|
|
38
|
+
videoBitrate: 4500,
|
|
39
|
+
audioBitrate: 128,
|
|
40
|
+
framerate: 30,
|
|
41
|
+
keyframeInterval: 1,
|
|
42
|
+
resolution: "1080p",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The RTMP ingest URLs of the places people actually go live. */
|
|
46
|
+
export const PRESETS: Record<string, string> = {
|
|
47
|
+
youtube: "rtmp://a.rtmp.youtube.com/live2",
|
|
48
|
+
x: "rtmp://ingest.x.com:1935/live",
|
|
49
|
+
facebook: "rtmps://live-api-s.facebook.com:443/rtmp",
|
|
50
|
+
tiktok: "rtmp://push.tiktokcdn.com/live",
|
|
51
|
+
twitch: "rtmp://live.twitch.tv/app",
|
|
52
|
+
kick: "rtmps://fa723fc1b171.global-contribute.live-video.net:443/app",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function resolutionOf(resolution: EncoderSettings["resolution"]): { width: number; height: number } {
|
|
56
|
+
return resolution === "720p" ? { width: 1280, height: 720 } : { width: 1920, height: 1080 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The full ingest URL. Built here so a key is never assembled by a client. */
|
|
60
|
+
export function ingestUrl(destination: Destination): string {
|
|
61
|
+
return `${destination.url.replace(/\/+$/, "")}/${destination.key}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Somewhere to actually send RTMP. */
|
|
65
|
+
export function isRtmp(url: string): boolean {
|
|
66
|
+
return /^rtmps?:\/\/[^\s/]+/i.test(url);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A destination as it may be shown to anyone. A stream key is a password: it
|
|
71
|
+
* lets a stranger broadcast as you until you rotate it.
|
|
72
|
+
*/
|
|
73
|
+
export function redact(destination: Destination): Omit<Destination, "key"> & { key: string } {
|
|
74
|
+
const tail = destination.key.slice(-4);
|
|
75
|
+
return { ...destination, key: destination.key ? `••••${tail}` : "" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A tee output. `onfail=ignore` is the important part: without it one dead
|
|
80
|
+
* destination takes the whole broadcast down with it, and the one that dies is
|
|
81
|
+
* usually the one whose key expired without telling you.
|
|
82
|
+
*/
|
|
83
|
+
export function teeOutput(url: string, options: string[] = ["f=flv"]): string {
|
|
84
|
+
return `[${[...options, "onfail=ignore"].join(":")}]${url}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface BroadcastPlan {
|
|
88
|
+
source: string;
|
|
89
|
+
destinations: Destination[];
|
|
90
|
+
settings: EncoderSettings;
|
|
91
|
+
/** Also produce web-playable audio on stdout, from the same decode. */
|
|
92
|
+
webAudio: boolean;
|
|
93
|
+
/** The source has no video track, so one has to be invented for RTMP. */
|
|
94
|
+
needsVideo: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The whole ffmpeg command.
|
|
99
|
+
*
|
|
100
|
+
* RTMP platforms want a video track even when what you are sending is music,
|
|
101
|
+
* so a silent source gets a flat colour at the chosen size. It is what a radio
|
|
102
|
+
* stream looks like on YouTube either way.
|
|
103
|
+
*/
|
|
104
|
+
export function buildBroadcastArgs(plan: BroadcastPlan): string[] {
|
|
105
|
+
const { width, height } = resolutionOf(plan.settings.resolution);
|
|
106
|
+
const gop = plan.settings.framerate * plan.settings.keyframeInterval;
|
|
107
|
+
|
|
108
|
+
const args = ["-hide_banner", "-loglevel", "error"];
|
|
109
|
+
|
|
110
|
+
// -re only for a file: a live source already arrives in real time, and
|
|
111
|
+
// throttling it a second time drifts further behind with every track.
|
|
112
|
+
if (!/^(https?|rtmps?|pipe):/i.test(plan.source) && plan.source !== "pipe:0") args.push("-re");
|
|
113
|
+
|
|
114
|
+
if (plan.needsVideo) {
|
|
115
|
+
args.push("-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=${plan.settings.framerate}`);
|
|
116
|
+
}
|
|
117
|
+
args.push("-i", plan.source);
|
|
118
|
+
|
|
119
|
+
// Video is always input 0: either the invented colour, or the source's own.
|
|
120
|
+
// Audio moves to input 1 when a colour was pushed in front of it.
|
|
121
|
+
args.push("-map", "0:v", "-map", plan.needsVideo ? "1:a" : "0:a");
|
|
122
|
+
|
|
123
|
+
args.push(
|
|
124
|
+
"-c:v", "libx264",
|
|
125
|
+
"-preset", "veryfast",
|
|
126
|
+
"-tune", "zerolatency",
|
|
127
|
+
"-b:v", `${plan.settings.videoBitrate}k`,
|
|
128
|
+
"-maxrate", `${Math.round(plan.settings.videoBitrate * 1.1)}k`,
|
|
129
|
+
"-bufsize", `${plan.settings.videoBitrate * 2}k`,
|
|
130
|
+
// A strict constant frame rate. A source that only produces frames when
|
|
131
|
+
// something changes reads to YouTube as a stream that is falling behind.
|
|
132
|
+
"-vf", `scale=${width}:${height},fps=${plan.settings.framerate}`,
|
|
133
|
+
"-pix_fmt", "yuv420p",
|
|
134
|
+
"-g", String(gop),
|
|
135
|
+
"-c:a", "aac",
|
|
136
|
+
"-b:a", `${plan.settings.audioBitrate}k`,
|
|
137
|
+
"-ar", "44100",
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const outputs = plan.destinations
|
|
141
|
+
.filter((d) => d.enabled && isRtmp(d.url))
|
|
142
|
+
.map((d) => teeOutput(ingestUrl(d)));
|
|
143
|
+
|
|
144
|
+
// The web copy rides along on the same encode, audio only, down stdout.
|
|
145
|
+
if (plan.webAudio) outputs.push(teeOutput("pipe:1", ["select=a", "f=mp3"]));
|
|
146
|
+
|
|
147
|
+
if (outputs.length === 0) return [];
|
|
148
|
+
|
|
149
|
+
// One output does not need the tee muxer, and ffmpeg reports its errors more
|
|
150
|
+
// clearly without it.
|
|
151
|
+
if (outputs.length === 1 && !plan.webAudio) {
|
|
152
|
+
const only = plan.destinations.find((d) => d.enabled && isRtmp(d.url));
|
|
153
|
+
args.push("-f", "flv", ingestUrl(only as Destination));
|
|
154
|
+
return args;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
args.push("-flags", "+global_header", "-f", "tee", outputs.join("|"));
|
|
158
|
+
return args;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type BroadcastState = "idle" | "live" | "failed";
|
|
162
|
+
|
|
163
|
+
export interface BroadcastStatus {
|
|
164
|
+
state: BroadcastState;
|
|
165
|
+
since: number | null;
|
|
166
|
+
/** Names only, and never a key. */
|
|
167
|
+
destinations: string[];
|
|
168
|
+
error: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One broadcast at a time, restarted when it dies. A live stream that stops
|
|
173
|
+
* because a platform hiccupped, and stays stopped, is worse than no feature.
|
|
174
|
+
*/
|
|
175
|
+
export class Broadcaster {
|
|
176
|
+
private child: ChildProcess | null = null;
|
|
177
|
+
private plan: BroadcastPlan | null = null;
|
|
178
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
179
|
+
private attempts = 0;
|
|
180
|
+
private state: BroadcastState = "idle";
|
|
181
|
+
private since: number | null = null;
|
|
182
|
+
private error = "";
|
|
183
|
+
|
|
184
|
+
constructor(
|
|
185
|
+
private readonly ffmpeg: string[] = ["ffmpeg"],
|
|
186
|
+
/** Injected so a test never waits five real seconds. */
|
|
187
|
+
private readonly delay = (ms: number, run: () => void) => setTimeout(run, ms),
|
|
188
|
+
) {}
|
|
189
|
+
|
|
190
|
+
status(): BroadcastStatus {
|
|
191
|
+
return {
|
|
192
|
+
state: this.state,
|
|
193
|
+
since: this.since,
|
|
194
|
+
destinations: (this.plan?.destinations ?? []).filter((d) => d.enabled).map((d) => d.name),
|
|
195
|
+
error: this.error,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
start(plan: BroadcastPlan): { ok: boolean; error: string } {
|
|
200
|
+
const args = buildBroadcastArgs(plan);
|
|
201
|
+
if (args.length === 0) return { ok: false, error: "no enabled destination with an rtmp url" };
|
|
202
|
+
|
|
203
|
+
this.stop();
|
|
204
|
+
this.plan = plan;
|
|
205
|
+
this.attempts = 0;
|
|
206
|
+
this.error = "";
|
|
207
|
+
this.spawn(args);
|
|
208
|
+
return { ok: true, error: "" };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
stop(): void {
|
|
212
|
+
if (this.timer) clearTimeout(this.timer);
|
|
213
|
+
this.timer = null;
|
|
214
|
+
this.plan = null;
|
|
215
|
+
this.state = "idle";
|
|
216
|
+
this.since = null;
|
|
217
|
+
const child = this.child;
|
|
218
|
+
this.child = null;
|
|
219
|
+
child?.kill("SIGKILL");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private spawn(args: string[]): void {
|
|
223
|
+
const [command, ...prefix] = this.ffmpeg as [string, ...string[]];
|
|
224
|
+
const child = spawn(command, [...prefix, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
225
|
+
this.child = child;
|
|
226
|
+
this.state = "live";
|
|
227
|
+
this.since = Date.now();
|
|
228
|
+
|
|
229
|
+
let tail = "";
|
|
230
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
231
|
+
tail = (tail + chunk.toString()).slice(-2000);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
child.on("error", (error) => {
|
|
235
|
+
this.error = error.message;
|
|
236
|
+
this.state = "failed";
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
child.on("close", (code) => {
|
|
240
|
+
if (this.child !== child) return; // stopped on purpose, or replaced
|
|
241
|
+
this.child = null;
|
|
242
|
+
if (code === 0) {
|
|
243
|
+
this.state = "idle";
|
|
244
|
+
this.since = null;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
this.error = tail.trim().split("\n").pop() ?? `ffmpeg exited ${code}`;
|
|
248
|
+
this.state = "failed";
|
|
249
|
+
this.retry();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Back off, but never give up entirely while a plan is set. */
|
|
254
|
+
private retry(): void {
|
|
255
|
+
const plan = this.plan;
|
|
256
|
+
if (plan === null) return;
|
|
257
|
+
this.attempts++;
|
|
258
|
+
const wait = Math.min(30_000, 1000 * 2 ** Math.min(5, this.attempts - 1));
|
|
259
|
+
this.timer = this.delay(wait, () => {
|
|
260
|
+
if (this.plan !== plan) return;
|
|
261
|
+
this.spawn(buildBroadcastArgs(plan));
|
|
262
|
+
}) as ReturnType<typeof setTimeout>;
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/channels.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Several streams at once.
|
|
3
|
+
*
|
|
4
|
+
* A channel is one live source and everybody listening to it. Two or three
|
|
5
|
+
* devices can publish at the same time -- a phone, a desktop, a second window
|
|
6
|
+
* -- and each has its own audience, so a listener picks which one to hear.
|
|
7
|
+
*
|
|
8
|
+
* The fan-out is the point. One ffmpeg decodes a publisher's bytes once, and
|
|
9
|
+
* the MP3 it produces is written to every listener attached to that channel.
|
|
10
|
+
* A decode per listener would cost a CPU core each and, for a live stream,
|
|
11
|
+
* would not even agree with itself about what "now" is.
|
|
12
|
+
*
|
|
13
|
+
* A listener joining halfway through gets the stream from that moment, which is
|
|
14
|
+
* what live means. MP3 frames are self-describing, so a player finds the next
|
|
15
|
+
* frame boundary and carries on; there is nothing to catch up on.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
import type { Readable } from "node:stream";
|
|
20
|
+
|
|
21
|
+
/** Somewhere for a channel's audio to go. A response, in practice. */
|
|
22
|
+
export interface Listener {
|
|
23
|
+
write(chunk: Buffer): boolean;
|
|
24
|
+
end(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChannelInfo {
|
|
28
|
+
id: string;
|
|
29
|
+
/** What the publisher called itself. */
|
|
30
|
+
name: string;
|
|
31
|
+
/** The container it is sending, e.g. webm from a browser, flv over RTMP. */
|
|
32
|
+
format: string;
|
|
33
|
+
/** How it arrived. */
|
|
34
|
+
via: "http" | "rtmp";
|
|
35
|
+
startedAt: number;
|
|
36
|
+
bytes: number;
|
|
37
|
+
listeners: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A name that can sit in a URL and be read back in a list. */
|
|
41
|
+
export function cleanId(value: unknown, fallback = "main"): string {
|
|
42
|
+
if (typeof value !== "string") return fallback;
|
|
43
|
+
const id = value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
44
|
+
return id.slice(0, 40) || fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ChannelOptions {
|
|
48
|
+
ffmpeg: string[];
|
|
49
|
+
onStart?: (info: ChannelInfo) => void;
|
|
50
|
+
onEnd?: (info: ChannelInfo) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One live source, and its audience.
|
|
55
|
+
*
|
|
56
|
+
* Everything a listener is sent has been through ffmpeg, so a publisher cannot
|
|
57
|
+
* decide what bytes reach a browser by choosing what to send.
|
|
58
|
+
*/
|
|
59
|
+
export class Channel {
|
|
60
|
+
readonly listeners = new Set<Listener>();
|
|
61
|
+
private child: ChildProcess | null = null;
|
|
62
|
+
private closing = false;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
readonly info: ChannelInfo,
|
|
66
|
+
private readonly options: ChannelOptions,
|
|
67
|
+
private readonly onGone: (id: string) => void,
|
|
68
|
+
) {}
|
|
69
|
+
|
|
70
|
+
start(format: string): void {
|
|
71
|
+
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
72
|
+
const child = spawn(
|
|
73
|
+
command,
|
|
74
|
+
[
|
|
75
|
+
...prefix,
|
|
76
|
+
"-hide_banner",
|
|
77
|
+
"-loglevel", "error",
|
|
78
|
+
// Stated, because ffmpeg mis-probes a live unseekable pipe: it reads a
|
|
79
|
+
// few kilobytes, guesses, and guesses wrong.
|
|
80
|
+
"-f", format,
|
|
81
|
+
"-i", "pipe:0",
|
|
82
|
+
"-vn",
|
|
83
|
+
"-c:a", "libmp3lame",
|
|
84
|
+
"-b:a", "192k",
|
|
85
|
+
"-f", "mp3",
|
|
86
|
+
"pipe:1",
|
|
87
|
+
],
|
|
88
|
+
{ stdio: ["pipe", "pipe", "pipe"] },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
92
|
+
this.info.bytes += chunk.byteLength;
|
|
93
|
+
this.send(chunk);
|
|
94
|
+
});
|
|
95
|
+
// A publisher that hangs up mid-write breaks the pipe, and an unhandled
|
|
96
|
+
// EPIPE takes the whole server with it.
|
|
97
|
+
child.stdin?.on("error", () => this.close());
|
|
98
|
+
child.stdout?.on("error", () => this.close());
|
|
99
|
+
child.on("error", () => this.close());
|
|
100
|
+
child.on("close", () => this.close());
|
|
101
|
+
|
|
102
|
+
this.child = child;
|
|
103
|
+
this.options.onStart?.(this.info);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Feed the source. */
|
|
107
|
+
write(chunk: Buffer): boolean {
|
|
108
|
+
return this.child?.stdin?.write(chunk) ?? false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async pump(body: Readable): Promise<void> {
|
|
112
|
+
for await (const chunk of body) {
|
|
113
|
+
if (this.closing) return;
|
|
114
|
+
if (!this.write(chunk as Buffer)) {
|
|
115
|
+
await new Promise((done) => this.child?.stdin?.once("drain", done) ?? done(null));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Audio that is already in its final form, from a source we did not spawn.
|
|
122
|
+
* The bytes still only reach a listener after something decoded them; it was
|
|
123
|
+
* simply a different process that did it.
|
|
124
|
+
*/
|
|
125
|
+
feed(chunk: Buffer): void {
|
|
126
|
+
this.info.bytes += chunk.byteLength;
|
|
127
|
+
this.send(chunk);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Write to everyone, and drop anybody whose socket has gone. */
|
|
131
|
+
private send(chunk: Buffer): void {
|
|
132
|
+
for (const listener of this.listeners) {
|
|
133
|
+
try {
|
|
134
|
+
listener.write(chunk);
|
|
135
|
+
} catch {
|
|
136
|
+
// One listener's broken socket is not the channel's problem.
|
|
137
|
+
this.listeners.delete(listener);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.info.listeners = this.listeners.size;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
listen(listener: Listener): () => void {
|
|
144
|
+
this.listeners.add(listener);
|
|
145
|
+
this.info.listeners = this.listeners.size;
|
|
146
|
+
return () => {
|
|
147
|
+
this.listeners.delete(listener);
|
|
148
|
+
this.info.listeners = this.listeners.size;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
close(): void {
|
|
153
|
+
if (this.closing) return;
|
|
154
|
+
this.closing = true;
|
|
155
|
+
const child = this.child;
|
|
156
|
+
this.child = null;
|
|
157
|
+
try {
|
|
158
|
+
child?.stdin?.end();
|
|
159
|
+
} catch {
|
|
160
|
+
// Already broken, which is usually why we are here.
|
|
161
|
+
}
|
|
162
|
+
child?.kill("SIGKILL");
|
|
163
|
+
// Listeners are ended rather than left hanging on a stream that stopped.
|
|
164
|
+
for (const listener of this.listeners) {
|
|
165
|
+
try {
|
|
166
|
+
listener.end();
|
|
167
|
+
} catch {
|
|
168
|
+
// Gone already.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
this.listeners.clear();
|
|
172
|
+
this.info.listeners = 0;
|
|
173
|
+
this.options.onEnd?.(this.info);
|
|
174
|
+
this.onGone(this.info.id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Every channel currently live.
|
|
180
|
+
*
|
|
181
|
+
* A channel exists while somebody is publishing to it and disappears when they
|
|
182
|
+
* stop, so the list is what is actually on rather than what was once
|
|
183
|
+
* configured.
|
|
184
|
+
*/
|
|
185
|
+
export class Channels {
|
|
186
|
+
private readonly open = new Map<string, Channel>();
|
|
187
|
+
|
|
188
|
+
constructor(private readonly options: ChannelOptions) {}
|
|
189
|
+
|
|
190
|
+
list(): ChannelInfo[] {
|
|
191
|
+
return [...this.open.values()]
|
|
192
|
+
.map((channel) => channel.info)
|
|
193
|
+
.sort((a, b) => a.startedAt - b.startedAt);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
get count(): number {
|
|
197
|
+
return this.open.size;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Total listeners across every channel. */
|
|
201
|
+
get listeners(): number {
|
|
202
|
+
let total = 0;
|
|
203
|
+
for (const channel of this.open.values()) total += channel.listeners.size;
|
|
204
|
+
return total;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
has(id: string): boolean {
|
|
208
|
+
return this.open.has(id);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Claim a channel and start decoding into it. Null when that channel is
|
|
213
|
+
* already being published to: two publishers on one channel would be two
|
|
214
|
+
* songs at once, which is never what anybody meant. Publishing to a
|
|
215
|
+
* *different* channel is exactly what this class exists for.
|
|
216
|
+
*/
|
|
217
|
+
publish(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null {
|
|
218
|
+
if (this.open.has(id)) return null;
|
|
219
|
+
const channel = new Channel(
|
|
220
|
+
{
|
|
221
|
+
id,
|
|
222
|
+
name: name || "a device",
|
|
223
|
+
format,
|
|
224
|
+
via,
|
|
225
|
+
startedAt: Date.now(),
|
|
226
|
+
bytes: 0,
|
|
227
|
+
listeners: 0,
|
|
228
|
+
},
|
|
229
|
+
this.options,
|
|
230
|
+
(gone) => this.open.delete(gone),
|
|
231
|
+
);
|
|
232
|
+
this.open.set(id, channel);
|
|
233
|
+
channel.start(format);
|
|
234
|
+
return channel;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Attach a listener, or null when nothing is playing on that channel. */
|
|
238
|
+
listen(id: string, listener: Listener): (() => void) | null {
|
|
239
|
+
const channel = this.open.get(id);
|
|
240
|
+
return channel ? channel.listen(listener) : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
244
|
+
writeTo(id: string, chunk: Buffer): boolean {
|
|
245
|
+
return this.open.get(id)?.write(chunk) ?? false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A channel fed by audio somebody else is already decoding.
|
|
250
|
+
*
|
|
251
|
+
* An RTMP listener is an ffmpeg with a publisher on one end, and it produces
|
|
252
|
+
* MP3 on its own. Spawning a second ffmpeg to decode what the first one just
|
|
253
|
+
* decoded would double the work to arrive at the same bytes.
|
|
254
|
+
*/
|
|
255
|
+
attach(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null {
|
|
256
|
+
if (this.open.has(id)) return null;
|
|
257
|
+
const channel = new Channel(
|
|
258
|
+
{ id, name: name || "a device", format, via, startedAt: Date.now(), bytes: 0, listeners: 0 },
|
|
259
|
+
this.options,
|
|
260
|
+
(gone) => this.open.delete(gone),
|
|
261
|
+
);
|
|
262
|
+
this.open.set(id, channel);
|
|
263
|
+
return channel;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
stop(id: string): boolean {
|
|
267
|
+
const channel = this.open.get(id);
|
|
268
|
+
if (!channel) return false;
|
|
269
|
+
channel.close();
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
stopAll(): void {
|
|
274
|
+
for (const channel of [...this.open.values()]) channel.close();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
279
|
+
export function generatedId(): string {
|
|
280
|
+
return `s${randomBytes(3).toString("hex")}`;
|
|
281
|
+
}
|
package/src/connections.ts
CHANGED
|
@@ -129,6 +129,19 @@ export class Connections {
|
|
|
129
129
|
return [...live, ...done];
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Live connections that are actually hearing something. The state feed and
|
|
134
|
+
* the page are not listeners, and counting them would put a stream over the
|
|
135
|
+
* free allowance with nobody listening to it.
|
|
136
|
+
*/
|
|
137
|
+
get listening(): number {
|
|
138
|
+
let count = 0;
|
|
139
|
+
for (const item of this.items.values()) {
|
|
140
|
+
if (item.endedAt === null && (item.kind === "stream" || item.kind === "media")) count++;
|
|
141
|
+
}
|
|
142
|
+
return count;
|
|
143
|
+
}
|
|
144
|
+
|
|
132
145
|
get active(): number {
|
|
133
146
|
let count = 0;
|
|
134
147
|
for (const item of this.items.values()) if (item.endedAt === null) count++;
|
package/src/directory.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public directory.
|
|
3
|
+
*
|
|
4
|
+
* A nixamp that agrees to be listed announces itself to nixamp.com every so
|
|
5
|
+
* often and is forgotten when it stops. There is no database behind it: an
|
|
6
|
+
* entry lives for a few minutes and a heartbeat renews it, so a restart of the
|
|
7
|
+
* directory costs one heartbeat rather than a migration, and a stream that
|
|
8
|
+
* dies falls out of the list without anyone having to notice.
|
|
9
|
+
*
|
|
10
|
+
* What is published is the *listen* link. The control key never leaves the
|
|
11
|
+
* machine it was minted on.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** How long an entry survives without a heartbeat. */
|
|
15
|
+
export const TTL_MS = 4 * 60 * 1000;
|
|
16
|
+
/** How often a publisher renews. Comfortably inside the TTL. */
|
|
17
|
+
export const HEARTBEAT_MS = 90 * 1000;
|
|
18
|
+
export const DEFAULT_DIRECTORY = "https://nixamp.com";
|
|
19
|
+
|
|
20
|
+
export interface Listing {
|
|
21
|
+
/** Assigned by the directory, so a publisher cannot claim someone else's. */
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
/** The listen link, which is what a browser opens. */
|
|
25
|
+
url: string;
|
|
26
|
+
tracks: number;
|
|
27
|
+
nowPlaying: string;
|
|
28
|
+
/** Set by the directory from the request, never by the publisher. */
|
|
29
|
+
updatedAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What a publisher sends. Everything else about a listing is ours to decide. */
|
|
33
|
+
export interface Announcement {
|
|
34
|
+
id?: string;
|
|
35
|
+
name: string;
|
|
36
|
+
url: string;
|
|
37
|
+
tracks: number;
|
|
38
|
+
nowPlaying: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_NAME = 60;
|
|
42
|
+
const MAX_TRACK = 120;
|
|
43
|
+
|
|
44
|
+
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
45
|
+
export function clean(value: unknown, max: number): string {
|
|
46
|
+
if (typeof value !== "string") return "";
|
|
47
|
+
// Control characters include the escape that starts an ANSI sequence, and
|
|
48
|
+
// this text is rendered in a terminal as well as a browser.
|
|
49
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A URL we are willing to list. It has to be somewhere a browser can go, and
|
|
54
|
+
* it must not be a loopback or link-local address: those are only reachable
|
|
55
|
+
* from the machine that published them, so listing one is an entry nobody but
|
|
56
|
+
* the publisher can ever open.
|
|
57
|
+
*/
|
|
58
|
+
export function publishable(raw: string): URL | null {
|
|
59
|
+
let url: URL;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(raw);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
66
|
+
|
|
67
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
68
|
+
if (host === "localhost" || host === "::1" || host.endsWith(".localhost")) return null;
|
|
69
|
+
if (/^127\./.test(host) || /^169\.254\./.test(host)) return null;
|
|
70
|
+
return url;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseAnnouncement(input: unknown): Announcement | null {
|
|
74
|
+
if (typeof input !== "object" || input === null) return null;
|
|
75
|
+
const record = input as Record<string, unknown>;
|
|
76
|
+
|
|
77
|
+
const url = typeof record["url"] === "string" ? record["url"] : "";
|
|
78
|
+
if (publishable(url) === null) return null;
|
|
79
|
+
|
|
80
|
+
const name = clean(record["name"], MAX_NAME);
|
|
81
|
+
const tracks = Number(record["tracks"]);
|
|
82
|
+
return {
|
|
83
|
+
...(typeof record["id"] === "string" ? { id: clean(record["id"], 40) } : {}),
|
|
84
|
+
name: name || "a nixamp",
|
|
85
|
+
url,
|
|
86
|
+
tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
|
|
87
|
+
nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The registry. In memory on purpose: see the note at the top of the file.
|
|
93
|
+
* One entry per URL, so a publisher restarting does not leave a ghost of
|
|
94
|
+
* itself behind next to the entry that replaced it.
|
|
95
|
+
*/
|
|
96
|
+
export class Directory {
|
|
97
|
+
private readonly items = new Map<string, Listing>();
|
|
98
|
+
private sequence = 0;
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
private readonly ttl = TTL_MS,
|
|
102
|
+
private readonly now: () => number = Date.now,
|
|
103
|
+
) {}
|
|
104
|
+
|
|
105
|
+
announce(announcement: Announcement): Listing {
|
|
106
|
+
this.sweep();
|
|
107
|
+
const existing = [...this.items.values()].find((item) => item.url === announcement.url);
|
|
108
|
+
const id = existing?.id ?? `s${++this.sequence}${this.now().toString(36)}`;
|
|
109
|
+
const listing: Listing = {
|
|
110
|
+
id,
|
|
111
|
+
name: announcement.name,
|
|
112
|
+
url: announcement.url,
|
|
113
|
+
tracks: announcement.tracks,
|
|
114
|
+
nowPlaying: announcement.nowPlaying,
|
|
115
|
+
updatedAt: this.now(),
|
|
116
|
+
};
|
|
117
|
+
this.items.set(id, listing);
|
|
118
|
+
return listing;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
withdraw(id: string): void {
|
|
122
|
+
this.items.delete(id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
list(): Listing[] {
|
|
126
|
+
this.sweep();
|
|
127
|
+
return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Forget anything that stopped renewing. */
|
|
131
|
+
private sweep(): void {
|
|
132
|
+
const cutoff = this.now() - this.ttl;
|
|
133
|
+
for (const [id, item] of this.items) if (item.updatedAt < cutoff) this.items.delete(id);
|
|
134
|
+
}
|
|
135
|
+
}
|