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/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;
|
package/dist/accounts.js
ADDED
|
@@ -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
|
+
}
|