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.
Files changed (51) hide show
  1. package/README.md +171 -0
  2. package/dist/accounts.d.ts +54 -0
  3. package/dist/accounts.js +160 -0
  4. package/dist/broadcast.d.ts +96 -0
  5. package/dist/broadcast.js +193 -0
  6. package/dist/channels.d.ts +94 -0
  7. package/dist/channels.js +235 -0
  8. package/dist/connections.d.ts +6 -0
  9. package/dist/connections.js +13 -0
  10. package/dist/directory.d.ts +63 -0
  11. package/dist/directory.js +111 -0
  12. package/dist/ingest.d.ts +80 -0
  13. package/dist/ingest.js +252 -0
  14. package/dist/main.js +21 -0
  15. package/dist/manage.js +2 -1
  16. package/dist/owner.d.ts +53 -0
  17. package/dist/owner.js +96 -0
  18. package/dist/paywall.d.ts +60 -0
  19. package/dist/paywall.js +162 -0
  20. package/dist/publish.d.ts +36 -0
  21. package/dist/publish.js +90 -0
  22. package/dist/rtmp-in.d.ts +22 -0
  23. package/dist/rtmp-in.js +79 -0
  24. package/dist/server.d.ts +79 -0
  25. package/dist/server.js +609 -10
  26. package/dist/session.d.ts +29 -0
  27. package/dist/session.js +184 -0
  28. package/dist/share.d.ts +16 -0
  29. package/dist/share.js +19 -0
  30. package/package.json +5 -2
  31. package/src/accounts.ts +193 -0
  32. package/src/broadcast.ts +264 -0
  33. package/src/channels.ts +281 -0
  34. package/src/connections.ts +13 -0
  35. package/src/directory.ts +135 -0
  36. package/src/ingest.ts +297 -0
  37. package/src/main.ts +21 -0
  38. package/src/manage.ts +2 -1
  39. package/src/owner.ts +113 -0
  40. package/src/paywall.ts +198 -0
  41. package/src/publish.ts +101 -0
  42. package/src/rtmp-in.ts +90 -0
  43. package/src/server.ts +702 -10
  44. package/src/session.ts +209 -0
  45. package/src/share.ts +27 -0
  46. package/src/types/auth-system.d.ts +77 -0
  47. package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
  48. package/web/dist/assets/index-WYJ6R4uF.js +1 -0
  49. package/web/dist/index.html +37 -6
  50. package/web/dist/sw.js +3 -3
  51. package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/README.md CHANGED
@@ -75,6 +75,152 @@ network. Your music is never touched.
75
75
  If you would rather not pipe a script into a shell, `npm i -g nixamp` and
76
76
  `bunx nixamp ~/Music` both work; that route needs Node 24 or newer.
77
77
 
78
+ ## Signing in
79
+
80
+ An account on nixamp.com is what lets you publish, be paid, and administer a
81
+ server you own. Email and password, on every surface:
82
+
83
+ ```
84
+ nixamp login # or: nixamp signup
85
+ nixamp whoami
86
+ nixamp logout
87
+ ```
88
+
89
+ The PWA and the desktop app share one form, since the desktop is that page in a
90
+ window. The CLI keeps its token beside the daemon's state, mode 600, so signing
91
+ in there and in the desktop app are the same thing on disk. The password is read
92
+ with the echo off and is never written down.
93
+
94
+ No magic link. A link in an inbox is no use on a television, or on a phone that
95
+ is not the one you read mail on.
96
+
97
+ Running the account side of nixamp.com needs Postgres:
98
+
99
+ ```
100
+ DATABASE_URL=postgres://user:pass@host/nixamp NIXAMP_JWT_SECRET=… nixamp serve --directory
101
+ ```
102
+
103
+ Accounts live where the directory lives and nowhere else: a nixamp on a laptop
104
+ has nobody to be an account of.
105
+
106
+ ## The directory
107
+
108
+ [nixamp.com/directory](https://nixamp.com/directory) lists nixamps that agreed
109
+ to be listed. In the PWA, **Browse the directory** next to the address field
110
+ picks one without typing anything.
111
+
112
+ `nixamp serve` asks before listing you, and shows the exact link it would
113
+ publish:
114
+
115
+ ```
116
+ List this stream at https://nixamp.com/directory so anyone can find it?
117
+ It publishes http://198.51.100.7:4321/s/Lk1EM_mP977e1VT — listen only,
118
+ not the controls. [Y/n]
119
+ ```
120
+
121
+ Yes is the default; `--publish` and `--no-publish` skip the question, `--name`
122
+ sets what it is called. A terminal that cannot ask never publishes, because
123
+ nobody being there to answer is not consent.
124
+
125
+ What gets published is a **listen-only** link. Every server mints two keys: the
126
+ one in your own share link drives the player, and the listen key can hear it
127
+ and nothing else. `/api/command` and `/api/source` answer 403 to a listen key,
128
+ so a stranger in the directory cannot pause your music or point your machine at
129
+ something else.
130
+
131
+ Entries expire a few minutes after a stream stops renewing, so the list is
132
+ always what is actually live.
133
+
134
+ ## Several streams at once
135
+
136
+ A channel is one publisher and everybody listening to them. Two or three devices
137
+ can be live at the same time -- a phone, a desktop, a second window -- each with
138
+ its own audience.
139
+
140
+ ```
141
+ GET /api/channels what is live now
142
+ POST /api/channels/<id> publish to one
143
+ GET /api/channels/<id> listen to one
144
+ ```
145
+
146
+ One ffmpeg decodes each publisher once and the result is written to every
147
+ listener on that channel. A decode per listener would cost a core each and, for
148
+ a live stream, would not even agree with itself about what "now" is.
149
+
150
+ A listener who joins halfway through gets the stream from that moment, which is
151
+ what live means. Two publishers on **one** channel is refused; on two channels it
152
+ is the whole point.
153
+
154
+ Publishing is administering the server, so it needs the control link or the
155
+ owner's account. Listening only needs the share link, like any other audio.
156
+
157
+ ## Streaming into it
158
+
159
+ A nixamp can be the thing you broadcast *to*, not just from.
160
+
161
+ ```
162
+ nixamp serve ~/Music --rtmp-in 1935
163
+ ```
164
+
165
+ Then point OBS, Larix, or another ffmpeg at the URL it prints. RTMP is what
166
+ every native broadcaster already speaks, so there is no nixamp-shaped client to
167
+ install. ffmpeg does the listening, so this costs no extra dependency.
168
+
169
+ A browser cannot speak RTMP at all, so the web app uses HTTP instead: one long
170
+ `POST /api/ingest` where the platform allows a streaming request body, and
171
+ `POST /api/ingest/chunk` where it does not. All three end up in the same place.
172
+
173
+ One publisher at a time. A second is refused rather than mixed.
174
+
175
+ ## Broadcasting out
176
+
177
+ Out to as many places as you like, at once:
178
+
179
+ ```
180
+ nixamp serve ~/Music --rtmp youtube=<key> --rtmp x=<key> --rtmp tiktok=<key>
181
+ ```
182
+
183
+ `youtube`, `x`, `facebook`, `tiktok`, `twitch` and `kick` are known by name and
184
+ need only a key; anything else takes a full `rtmp://host/app/key`.
185
+
186
+ One ffmpeg, one encode, many outputs, through the `tee` muxer. An ffmpeg per
187
+ destination is the obvious shape and it encodes the same frames four times.
188
+ Every output carries `onfail=ignore`, so one destination with an expired key
189
+ cannot take the others down with it.
190
+
191
+ The encoder settings come from PairUX, which learned them against the real
192
+ platforms: a one-second keyframe interval because YouTube stalls on ffmpeg's
193
+ default, a forced constant frame rate because a variable-rate source makes
194
+ YouTube report a stream falling behind, and `yuv420p` because that is what RTMP
195
+ platforms accept. Music has no picture, so a flat colour is generated: RTMP
196
+ wants a video track either way.
197
+
198
+ Stream keys are read from the command line or the environment and never from a
199
+ request. `/api/broadcast/destinations` shows names and URLs with the keys
200
+ redacted.
201
+
202
+ ## Paying to listen
203
+
204
+ A stream serving a handful of friends costs nothing and asks nothing. Past five
205
+ people listening at once it is bandwidth somebody is paying for, so the gate
206
+ opens: the sixth listener gets a 402 with an
207
+ [x402](https://github.com/profullstack/x402-gateway) offer, and a dollar buys a
208
+ day.
209
+
210
+ ```
211
+ NIXAMP_PAY_TO=0xYourAddress COINPAY_X402_KEY=cp_live_… nixamp serve ~/Music --x402
212
+ ```
213
+
214
+ Three things are deliberate. The count is of *live* listeners, so a stream
215
+ quietens back to free on its own. Only the audio is gated: a 402 on `/api/state`
216
+ would break the page that has to render the offer. And nobody is cut off
217
+ mid-track, because the gate is asked once, when a request arrives.
218
+
219
+ `NIXAMP_PRICE_CENTS` and `NIXAMP_PASS_MINUTES` change the terms; the defaults are
220
+ 100 and 1440, which is the dollar and the day. A server that has agreed to be in
221
+ the directory can also be switched on and off from nixamp.com: the configuration
222
+ rides back on the heartbeat it is already sending.
223
+
78
224
  ## Leaving it running
79
225
 
80
226
  `nixamp serve` holds a terminal. `nixamp daemon` does not.
@@ -89,6 +235,31 @@ Start writes down where it went and the key it minted, waits until the server
89
235
  is actually answering before saying it started, and prints the share link. It is
90
236
  one daemon per user, and the state lives in `$XDG_STATE_HOME/nixamp`.
91
237
 
238
+ ## Who may administer a server
239
+
240
+ Two ways to be allowed, and they answer different questions.
241
+
242
+ **You hold its control link.** That is possession: you are at the machine, or
243
+ somebody at it sent you the link. It works with no account and no network.
244
+
245
+ **You own it.** `nixamp login` and then `nixamp serve` claims the server for the
246
+ account signed in on that machine, and from then on that account can administer
247
+ it from a phone anywhere, by signing in to nixamp.com in the browser.
248
+
249
+ The server cannot check a nixamp.com token itself, and should not: it holds no
250
+ part of that secret. It asks nixamp.com who the token belongs to and compares
251
+ the answer to the owner it recorded at startup. Delegating identity while
252
+ keeping authorisation local is what lets a nixamp on a laptop trust an account
253
+ it has never seen.
254
+
255
+ Answers are remembered for a minute, so admin requests do not each cost a round
256
+ trip, and a revoked session stops working in about a minute rather than at the
257
+ next restart. If nixamp.com cannot be reached, nobody becomes the owner — the
258
+ control link is the way in until it can.
259
+
260
+ Listening is never affected: `/api/state`, `/api/stream` and the page itself
261
+ stay open to whoever has the share link.
262
+
92
263
  ## Watching it
93
264
 
94
265
  ```
@@ -0,0 +1,54 @@
1
+ export interface Account {
2
+ id: string;
3
+ email: string;
4
+ }
5
+ export interface AuthResult {
6
+ ok: boolean;
7
+ account: Account | null;
8
+ token: string;
9
+ /** Safe to show a stranger: it never says whether an address is registered. */
10
+ error: string;
11
+ }
12
+ export interface AccountsOptions {
13
+ /** postgres://user:pass@host/db */
14
+ connectionString: string;
15
+ /** Signing secret. Without one, every session dies on restart. */
16
+ secret: string;
17
+ /** Injected by the tests, which have no database. */
18
+ system?: AuthLike;
19
+ }
20
+ /** The slice of the auth system nixamp uses. */
21
+ export interface AuthLike {
22
+ register(input: {
23
+ email: string;
24
+ password: string;
25
+ autoVerify?: boolean;
26
+ }): Promise<unknown>;
27
+ login(input: {
28
+ email: string;
29
+ password: string;
30
+ }): Promise<unknown>;
31
+ validateToken(token: string): Promise<unknown>;
32
+ }
33
+ /** Pull an account and a token out of whatever shape the module returned. */
34
+ export declare function readResult(value: unknown): AuthResult;
35
+ /** `validateToken` answers claims directly, unlike login and register. */
36
+ export declare function readClaims(value: unknown): Account | null;
37
+ /** An address that could exist, and a password long enough to be worth having. */
38
+ export declare function checkCredentials(email: unknown, password: unknown): string;
39
+ export declare class Accounts {
40
+ private readonly system;
41
+ constructor(options: AccountsOptions);
42
+ signUp(email: unknown, password: unknown): Promise<AuthResult>;
43
+ signIn(email: unknown, password: unknown): Promise<AuthResult>;
44
+ whoIs(token: string): Promise<Account | null>;
45
+ }
46
+ /** The bearer token on a request, from the header or the session cookie. */
47
+ export declare function tokenFrom(headers: Record<string, string | string[] | undefined>): string;
48
+ /**
49
+ * The session cookie. HttpOnly because nothing in the page reads it -- the
50
+ * browser attaches it by itself -- and Secure only where the page was served
51
+ * over https, since a nixamp on your own network is plain http.
52
+ */
53
+ export declare function sessionCookie(token: string, secure: boolean): string;
54
+ export declare function clearedCookie(): string;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Accounts on nixamp.com.
3
+ *
4
+ * The house auth module does the work: password and JWT, over the Postgres
5
+ * adapter. This is the shape nixamp needs around it, and the two things the
6
+ * module gets wrong from a caller's point of view:
7
+ *
8
+ * - `login()` and `register()` THROW on a bad password or a taken address
9
+ * rather than resolving `{ success: false }`, so a bare `if (!result.success)`
10
+ * never runs. Everything here answers a result instead.
11
+ * - `validateToken()` resolves to the claims directly, not to a wrapper like
12
+ * the other two, so the shapes differ between calls.
13
+ * - `register()` without `autoVerify` creates an account that `login()` will
14
+ * refuse for ever, and returns no tokens. nixamp sends no email, so there
15
+ * would be nothing to click.
16
+ *
17
+ * No magic link: a link in an inbox is no use on a television or a phone that
18
+ * is not the one you read mail on.
19
+ */
20
+ import { createAuthSystem, PostgresAdapter } from "@profullstack/auth-system";
21
+ const NO_ACCOUNT = { ok: false, account: null, token: "", error: "" };
22
+ /**
23
+ * The same sentence for a wrong password and an address with no account.
24
+ * Saying which is how an endpoint tells a stranger who has registered.
25
+ */
26
+ const REFUSED = "that email and password do not match an account";
27
+ /** Pull an account and a token out of whatever shape the module returned. */
28
+ export function readResult(value) {
29
+ const record = (value ?? {});
30
+ const user = (record["user"] ?? {});
31
+ const tokens = (record["tokens"] ?? {});
32
+ const id = typeof user["id"] === "string" ? user["id"] : "";
33
+ const email = typeof user["email"] === "string" ? user["email"] : "";
34
+ const token = typeof tokens["accessToken"] === "string" ? tokens["accessToken"] : "";
35
+ if (!id || !token)
36
+ return { ...NO_ACCOUNT, error: REFUSED };
37
+ return { ok: true, account: { id, email }, token, error: "" };
38
+ }
39
+ /** `validateToken` answers claims directly, unlike login and register. */
40
+ export function readClaims(value) {
41
+ const claims = (value ?? {});
42
+ const id = typeof claims["userId"] === "string" ? claims["userId"] : "";
43
+ const email = typeof claims["email"] === "string" ? claims["email"] : "";
44
+ return id ? { id, email } : null;
45
+ }
46
+ /** An address that could exist, and a password long enough to be worth having. */
47
+ export function checkCredentials(email, password) {
48
+ if (typeof email !== "string" || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
49
+ return "that does not look like an email address";
50
+ }
51
+ if (typeof password !== "string" || password.length < 10) {
52
+ // Length is checked here so a hopeless password never reaches the
53
+ // database. The auth module then applies its own composition rules on top,
54
+ // and its refusals are passed through rather than swallowed.
55
+ return "a password needs at least 10 characters";
56
+ }
57
+ if (password.length > 200)
58
+ return "that password is too long";
59
+ return "";
60
+ }
61
+ export class Accounts {
62
+ system;
63
+ constructor(options) {
64
+ this.system =
65
+ options.system ??
66
+ createAuthSystem({
67
+ adapter: new PostgresAdapter({ connectionString: options.connectionString }),
68
+ jwtSecret: options.secret,
69
+ });
70
+ }
71
+ async signUp(email, password) {
72
+ const wrong = checkCredentials(email, password);
73
+ if (wrong)
74
+ return { ...NO_ACCOUNT, error: wrong };
75
+ try {
76
+ // autoVerify does two things, and both are necessary here: without it
77
+ // the account is created unverified and login() refuses it forever --
78
+ // nixamp sends no email, so there is nothing to click -- and register()
79
+ // returns no tokens, so signing up would not sign you in.
80
+ return readResult(await this.system.register({
81
+ email: email,
82
+ password: password,
83
+ autoVerify: true,
84
+ }));
85
+ }
86
+ catch (error) {
87
+ const message = error.message ?? "";
88
+ // "already exists" is the one case worth naming: a sign-up form that
89
+ // will not say why is a sign-up form people give up on. It reveals
90
+ // nothing that trying to sign up does not reveal anyway.
91
+ if (/exist|taken|duplicate/i.test(message)) {
92
+ return { ...NO_ACCOUNT, error: "there is already an account with that email" };
93
+ }
94
+ // The module has its own password rules -- an uppercase letter, and so
95
+ // on -- and refuses with a sentence saying which. Hiding that behind
96
+ // "could not create that account" leaves someone retyping a password
97
+ // that will never be accepted.
98
+ const complaint = /^Invalid (?:password|email)[:\s]+(.*)$/i.exec(message);
99
+ if (complaint?.[1])
100
+ return { ...NO_ACCOUNT, error: complaint[1].trim().toLowerCase() };
101
+ return { ...NO_ACCOUNT, error: "could not create that account" };
102
+ }
103
+ }
104
+ async signIn(email, password) {
105
+ if (checkCredentials(email, password))
106
+ return { ...NO_ACCOUNT, error: REFUSED };
107
+ try {
108
+ return readResult(await this.system.login({ email: email, password: password }));
109
+ }
110
+ catch {
111
+ // login() throws on bad credentials, so this is the ordinary path.
112
+ return { ...NO_ACCOUNT, error: REFUSED };
113
+ }
114
+ }
115
+ async whoIs(token) {
116
+ if (!token)
117
+ return null;
118
+ try {
119
+ return readClaims(await this.system.validateToken(token));
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ }
126
+ /** The bearer token on a request, from the header or the session cookie. */
127
+ export function tokenFrom(headers) {
128
+ const authorization = headers["authorization"];
129
+ const header = Array.isArray(authorization) ? authorization[0] : authorization;
130
+ const bearer = /^Bearer\s+(.+)$/i.exec(header ?? "")?.[1];
131
+ if (bearer)
132
+ return bearer.trim();
133
+ const cookie = Array.isArray(headers["cookie"]) ? headers["cookie"][0] : headers["cookie"];
134
+ for (const part of (cookie ?? "").split(";")) {
135
+ const [name, ...rest] = part.trim().split("=");
136
+ if (name === "nixamp_session" && rest.length > 0)
137
+ return decodeURIComponent(rest.join("="));
138
+ }
139
+ return "";
140
+ }
141
+ /**
142
+ * The session cookie. HttpOnly because nothing in the page reads it -- the
143
+ * browser attaches it by itself -- and Secure only where the page was served
144
+ * over https, since a nixamp on your own network is plain http.
145
+ */
146
+ export function sessionCookie(token, secure) {
147
+ const parts = [
148
+ `nixamp_session=${encodeURIComponent(token)}`,
149
+ "Path=/",
150
+ "Max-Age=2592000",
151
+ "SameSite=Lax",
152
+ "HttpOnly",
153
+ ];
154
+ if (secure)
155
+ parts.push("Secure");
156
+ return parts.join("; ");
157
+ }
158
+ export function clearedCookie() {
159
+ return "nixamp_session=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly";
160
+ }
@@ -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
+ }