nixamp 0.2.0 → 0.4.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 +186 -0
- package/dist/directory.js +275 -0
- package/dist/durable.d.ts +70 -0
- package/dist/durable.js +156 -0
- package/dist/follows.d.ts +92 -0
- package/dist/follows.js +248 -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/notify.d.ts +83 -0
- package/dist/notify.js +126 -0
- package/dist/optin.d.ts +37 -0
- package/dist/optin.js +122 -0
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/partyline.d.ts +259 -0
- package/dist/partyline.js +616 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/playlist.js +5 -0
- package/dist/publish.d.ts +57 -0
- package/dist/publish.js +106 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +94 -0
- package/dist/server.js +1158 -12
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +26 -0
- package/dist/share.js +31 -0
- package/package.json +8 -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 +362 -0
- package/src/durable.ts +215 -0
- package/src/follows.ts +307 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/notify.ts +217 -0
- package/src/optin.ts +128 -0
- package/src/owner.ts +113 -0
- package/src/partyline.ts +742 -0
- package/src/paywall.ts +198 -0
- package/src/playlist.ts +5 -0
- package/src/publish.ts +137 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +1304 -12
- package/src/session.ts +209 -0
- package/src/share.ts +40 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
- package/web/dist/assets/index-qRguFskX.js +1 -0
- package/web/dist/index.html +62 -6
- package/web/dist/install.sh +82 -0
- package/web/dist/sw.js +45 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/dist/follows.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Following a broadcaster, and where to reach the people who do.
|
|
3
|
+
*
|
|
4
|
+
* The phone line got here first, and its reminder is a different thing: you
|
|
5
|
+
* key a code, press 1, and are told once when that particular stream comes
|
|
6
|
+
* back. It is per-stream, one-shot, and tied to the handset you called from.
|
|
7
|
+
* That is right for somebody who dialled a number, and useless for somebody
|
|
8
|
+
* who wants to know whenever a person they like goes live, on whatever device
|
|
9
|
+
* they happen to be holding.
|
|
10
|
+
*
|
|
11
|
+
* So a follow is account to account, and delivery is a set of addresses rather
|
|
12
|
+
* than a phone number: an email, a phone if they gave one, and any number of
|
|
13
|
+
* browsers that have granted permission. One person with a laptop, a phone and
|
|
14
|
+
* a desktop app is three push subscriptions and one account.
|
|
15
|
+
*
|
|
16
|
+
* Unlike the rest of nixamp this is durable. The directory can afford to be a
|
|
17
|
+
* four-minute TTL because a stream that stops is a stream nobody is listening
|
|
18
|
+
* to; a follow has to outlive the stream by definition -- the whole point is
|
|
19
|
+
* to be told about a broadcast that is not happening yet.
|
|
20
|
+
*
|
|
21
|
+
* The query function is injected rather than a Pool being constructed here, so
|
|
22
|
+
* a test can describe a database instead of running one.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The tables.
|
|
26
|
+
*
|
|
27
|
+
* Created on demand rather than in a migration because the auth module owns
|
|
28
|
+
* the schema this sits beside and there is no migration runner to hook into.
|
|
29
|
+
* `IF NOT EXISTS` throughout, so starting a second instance is not a race that
|
|
30
|
+
* takes the first one down.
|
|
31
|
+
*
|
|
32
|
+
* `follows` is keyed on the pair, which makes following twice a no-op rather
|
|
33
|
+
* than a duplicate to deduplicate later. `push_subscriptions` is keyed on the
|
|
34
|
+
* endpoint alone: an endpoint is issued by the browser vendor and is already
|
|
35
|
+
* unique, and the same browser re-subscribing should replace its row rather
|
|
36
|
+
* than accumulate one per sign-in.
|
|
37
|
+
*/
|
|
38
|
+
const SCHEMA = `
|
|
39
|
+
CREATE TABLE IF NOT EXISTS follows (
|
|
40
|
+
follower_id TEXT NOT NULL,
|
|
41
|
+
streamer_id TEXT NOT NULL,
|
|
42
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
43
|
+
PRIMARY KEY (follower_id, streamer_id)
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS follows_streamer ON follows (streamer_id);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
48
|
+
endpoint TEXT PRIMARY KEY,
|
|
49
|
+
account_id TEXT NOT NULL,
|
|
50
|
+
p256dh TEXT NOT NULL,
|
|
51
|
+
auth TEXT NOT NULL,
|
|
52
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS push_account ON push_subscriptions (account_id);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS notify_prefs (
|
|
57
|
+
account_id TEXT PRIMARY KEY,
|
|
58
|
+
phone TEXT NOT NULL DEFAULT '',
|
|
59
|
+
want_email BOOLEAN NOT NULL DEFAULT TRUE,
|
|
60
|
+
want_sms BOOLEAN NOT NULL DEFAULT FALSE,
|
|
61
|
+
want_web BOOLEAN NOT NULL DEFAULT TRUE
|
|
62
|
+
);
|
|
63
|
+
`;
|
|
64
|
+
/** E.164, or nothing. A number we cannot dial is not a number worth storing. */
|
|
65
|
+
export function phoneFrom(value) {
|
|
66
|
+
if (typeof value !== "string")
|
|
67
|
+
return "";
|
|
68
|
+
const digits = value.replace(/[^\d+]/g, "");
|
|
69
|
+
if (/^\+[1-9]\d{7,14}$/.test(digits))
|
|
70
|
+
return digits;
|
|
71
|
+
// A bare US ten-digit number is the common case and is unambiguous.
|
|
72
|
+
if (/^\d{10}$/.test(digits))
|
|
73
|
+
return `+1${digits}`;
|
|
74
|
+
if (/^1\d{10}$/.test(digits))
|
|
75
|
+
return `+${digits}`;
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
export class Follows {
|
|
79
|
+
db;
|
|
80
|
+
ready = null;
|
|
81
|
+
constructor(db) {
|
|
82
|
+
this.db = db;
|
|
83
|
+
}
|
|
84
|
+
/** Make the tables, once per process, on first use. */
|
|
85
|
+
async ensure() {
|
|
86
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
87
|
+
await this.ready;
|
|
88
|
+
}
|
|
89
|
+
async follow(followerId, streamerId) {
|
|
90
|
+
// Following yourself is not an error worth an error, but it is not a
|
|
91
|
+
// follow either: you do not need telling that you went live.
|
|
92
|
+
if (!followerId || !streamerId || followerId === streamerId)
|
|
93
|
+
return false;
|
|
94
|
+
await this.ensure();
|
|
95
|
+
await this.db.query(`INSERT INTO follows (follower_id, streamer_id) VALUES ($1, $2)
|
|
96
|
+
ON CONFLICT DO NOTHING`, [followerId, streamerId]);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
async unfollow(followerId, streamerId) {
|
|
100
|
+
await this.ensure();
|
|
101
|
+
await this.db.query("DELETE FROM follows WHERE follower_id = $1 AND streamer_id = $2", [followerId, streamerId]);
|
|
102
|
+
}
|
|
103
|
+
/** Who this account follows. */
|
|
104
|
+
async following(followerId) {
|
|
105
|
+
await this.ensure();
|
|
106
|
+
const { rows } = await this.db.query("SELECT streamer_id FROM follows WHERE follower_id = $1 ORDER BY created_at", [followerId]);
|
|
107
|
+
return rows.map((r) => String(r["streamer_id"]));
|
|
108
|
+
}
|
|
109
|
+
async isFollowing(followerId, streamerId) {
|
|
110
|
+
await this.ensure();
|
|
111
|
+
const { rows } = await this.db.query("SELECT 1 FROM follows WHERE follower_id = $1 AND streamer_id = $2", [followerId, streamerId]);
|
|
112
|
+
return rows.length > 0;
|
|
113
|
+
}
|
|
114
|
+
async followerCount(streamerId) {
|
|
115
|
+
await this.ensure();
|
|
116
|
+
const { rows } = await this.db.query("SELECT COUNT(*)::int AS n FROM follows WHERE streamer_id = $1", [streamerId]);
|
|
117
|
+
return Number(rows[0]?.["n"] ?? 0);
|
|
118
|
+
}
|
|
119
|
+
/** Remember a browser that has granted permission. */
|
|
120
|
+
async addPush(accountId, target) {
|
|
121
|
+
if (!accountId || !target.endpoint)
|
|
122
|
+
return;
|
|
123
|
+
await this.ensure();
|
|
124
|
+
await this.db.query(`INSERT INTO push_subscriptions (endpoint, account_id, p256dh, auth)
|
|
125
|
+
VALUES ($1, $2, $3, $4)
|
|
126
|
+
ON CONFLICT (endpoint) DO UPDATE
|
|
127
|
+
SET account_id = EXCLUDED.account_id,
|
|
128
|
+
p256dh = EXCLUDED.p256dh,
|
|
129
|
+
auth = EXCLUDED.auth`, [target.endpoint, accountId, target.p256dh, target.auth]);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Forget a browser.
|
|
133
|
+
*
|
|
134
|
+
* Called when somebody turns notifications off, and again when a push is
|
|
135
|
+
* rejected as gone: a subscription outlives the browser that made it, and
|
|
136
|
+
* pushing to a dead endpoint forever is how a table becomes mostly rubbish.
|
|
137
|
+
*/
|
|
138
|
+
async removePush(endpoint) {
|
|
139
|
+
if (!endpoint)
|
|
140
|
+
return;
|
|
141
|
+
await this.ensure();
|
|
142
|
+
await this.db.query("DELETE FROM push_subscriptions WHERE endpoint = $1", [endpoint]);
|
|
143
|
+
}
|
|
144
|
+
async setPrefs(accountId, prefs) {
|
|
145
|
+
if (!accountId)
|
|
146
|
+
return;
|
|
147
|
+
await this.ensure();
|
|
148
|
+
// Every column is COALESCEd on the way IN as well as on conflict. A caller
|
|
149
|
+
// setting only the switches sends no phone, and an unset field arrives as
|
|
150
|
+
// null -- which the ON CONFLICT branch handles fine and the INSERT branch
|
|
151
|
+
// does not, because these columns are NOT NULL. Saving preferences for the
|
|
152
|
+
// first time was a 500 until this said so.
|
|
153
|
+
//
|
|
154
|
+
// The casts are not decoration either: Postgres cannot infer the type of a
|
|
155
|
+
// parameter that is only ever seen inside COALESCE against a null.
|
|
156
|
+
await this.db.query(`INSERT INTO notify_prefs (account_id, phone, want_email, want_sms, want_web)
|
|
157
|
+
VALUES ($1,
|
|
158
|
+
COALESCE($2::text, ''),
|
|
159
|
+
COALESCE($3::boolean, TRUE),
|
|
160
|
+
COALESCE($4::boolean, FALSE),
|
|
161
|
+
COALESCE($5::boolean, TRUE))
|
|
162
|
+
ON CONFLICT (account_id) DO UPDATE
|
|
163
|
+
SET phone = COALESCE($2::text, notify_prefs.phone),
|
|
164
|
+
want_email = COALESCE($3::boolean, notify_prefs.want_email),
|
|
165
|
+
want_sms = COALESCE($4::boolean, notify_prefs.want_sms),
|
|
166
|
+
want_web = COALESCE($5::boolean, notify_prefs.want_web)`, [
|
|
167
|
+
accountId,
|
|
168
|
+
prefs.phone === undefined ? null : phoneFrom(prefs.phone),
|
|
169
|
+
prefs.wantsEmail ?? null,
|
|
170
|
+
prefs.wantsSms ?? null,
|
|
171
|
+
prefs.wantsWeb ?? null,
|
|
172
|
+
]);
|
|
173
|
+
}
|
|
174
|
+
async prefs(accountId) {
|
|
175
|
+
await this.ensure();
|
|
176
|
+
const { rows } = await this.db.query("SELECT phone, want_email, want_sms, want_web FROM notify_prefs WHERE account_id = $1", [accountId]);
|
|
177
|
+
const row = rows[0];
|
|
178
|
+
// Defaults for somebody who has never opened the settings: mail yes, web
|
|
179
|
+
// yes, texts no. A text costs them nothing but is the most intrusive of
|
|
180
|
+
// the three, so it is the one you have to ask for.
|
|
181
|
+
return {
|
|
182
|
+
phone: String(row?.["phone"] ?? ""),
|
|
183
|
+
wantsEmail: row === undefined ? true : row["want_email"] !== false,
|
|
184
|
+
wantsSms: row === undefined ? false : row["want_sms"] === true,
|
|
185
|
+
wantsWeb: row === undefined ? true : row["want_web"] !== false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Everyone following this broadcaster, and every way to reach them.
|
|
190
|
+
*
|
|
191
|
+
* One query rather than one per follower. A broadcaster with a thousand
|
|
192
|
+
* followers going live should not be a thousand round trips while the
|
|
193
|
+
* publisher's heartbeat waits on the response.
|
|
194
|
+
*/
|
|
195
|
+
async audience(streamerId) {
|
|
196
|
+
if (!streamerId)
|
|
197
|
+
return [];
|
|
198
|
+
await this.ensure();
|
|
199
|
+
const { rows } = await this.db.query(`SELECT f.follower_id AS account_id,
|
|
200
|
+
COALESCE(u.email, '') AS email,
|
|
201
|
+
COALESCE(p.phone, '') AS phone,
|
|
202
|
+
COALESCE(p.want_email, TRUE) AS want_email,
|
|
203
|
+
COALESCE(p.want_sms, FALSE) AS want_sms,
|
|
204
|
+
COALESCE(p.want_web, TRUE) AS want_web,
|
|
205
|
+
COALESCE(
|
|
206
|
+
json_agg(json_build_object('endpoint', s.endpoint, 'p256dh', s.p256dh, 'auth', s.auth))
|
|
207
|
+
FILTER (WHERE s.endpoint IS NOT NULL),
|
|
208
|
+
'[]'
|
|
209
|
+
) AS push
|
|
210
|
+
FROM follows f
|
|
211
|
+
LEFT JOIN users u ON u.id = f.follower_id
|
|
212
|
+
LEFT JOIN notify_prefs p ON p.account_id = f.follower_id
|
|
213
|
+
LEFT JOIN push_subscriptions s ON s.account_id = f.follower_id
|
|
214
|
+
WHERE f.streamer_id = $1
|
|
215
|
+
GROUP BY f.follower_id, u.email, p.phone, p.want_email, p.want_sms, p.want_web`, [streamerId]);
|
|
216
|
+
return rows.map((r) => ({
|
|
217
|
+
accountId: String(r["account_id"] ?? ""),
|
|
218
|
+
email: String(r["email"] ?? ""),
|
|
219
|
+
phone: String(r["phone"] ?? ""),
|
|
220
|
+
wantsEmail: r["want_email"] !== false,
|
|
221
|
+
wantsSms: r["want_sms"] === true,
|
|
222
|
+
wantsWeb: r["want_web"] !== false,
|
|
223
|
+
push: readPush(r["push"]),
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** json_agg comes back as an array or as a string, depending on the driver. */
|
|
228
|
+
function readPush(value) {
|
|
229
|
+
const list = typeof value === "string" ? safeParse(value) : value;
|
|
230
|
+
if (!Array.isArray(list))
|
|
231
|
+
return [];
|
|
232
|
+
return list
|
|
233
|
+
.map((item) => (item ?? {}))
|
|
234
|
+
.filter((item) => typeof item["endpoint"] === "string" && item["endpoint"] !== "")
|
|
235
|
+
.map((item) => ({
|
|
236
|
+
endpoint: String(item["endpoint"]),
|
|
237
|
+
p256dh: String(item["p256dh"] ?? ""),
|
|
238
|
+
auth: String(item["auth"] ?? ""),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
function safeParse(text) {
|
|
242
|
+
try {
|
|
243
|
+
return JSON.parse(text);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
}
|
package/dist/ingest.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
/** A live source is one session at a time: two would be two songs at once. */
|
|
3
|
+
export interface IngestSession {
|
|
4
|
+
id: string;
|
|
5
|
+
/** What the sender called itself. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** The container the sender is producing, e.g. webm from MediaRecorder. */
|
|
8
|
+
format: string;
|
|
9
|
+
startedAt: number;
|
|
10
|
+
bytes: number;
|
|
11
|
+
}
|
|
12
|
+
export interface IngestStatus {
|
|
13
|
+
live: boolean;
|
|
14
|
+
session: IngestSession | null;
|
|
15
|
+
/** Where a native broadcaster should publish, when one is being listened for. */
|
|
16
|
+
rtmp: {
|
|
17
|
+
port: number;
|
|
18
|
+
path: string;
|
|
19
|
+
} | null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* ffmpeg's `-f` is a demuxer name, and passing an unknown one is how a stream
|
|
23
|
+
* dies four seconds in with a message nobody sees. An unrecognised container is
|
|
24
|
+
* refused up front instead.
|
|
25
|
+
*/
|
|
26
|
+
export declare function normaliseFormat(value: unknown): string | null;
|
|
27
|
+
export interface IngestOptions {
|
|
28
|
+
ffmpeg: string[];
|
|
29
|
+
/** Where the decoded audio should go: a file ffmpeg writes, or a pipe. */
|
|
30
|
+
sink: string;
|
|
31
|
+
/** Called when a session starts, so the player can switch to it. */
|
|
32
|
+
onStart: (session: IngestSession) => void;
|
|
33
|
+
/** Called when it ends, cleanly or otherwise. */
|
|
34
|
+
onEnd: (session: IngestSession, error: string) => void;
|
|
35
|
+
/** Encoded audio, as it arrives from a live publisher. */
|
|
36
|
+
onAudio?: (chunk: Buffer) => void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The live input.
|
|
40
|
+
*
|
|
41
|
+
* One session at a time: a second sender is refused rather than mixed, because
|
|
42
|
+
* mixing two uninvited streams is never what anybody meant.
|
|
43
|
+
*/
|
|
44
|
+
export declare class Ingest {
|
|
45
|
+
private readonly options;
|
|
46
|
+
private child;
|
|
47
|
+
private session;
|
|
48
|
+
private closing;
|
|
49
|
+
/** The RTMP listener, which outlives any one publisher. */
|
|
50
|
+
private rtmpChild;
|
|
51
|
+
private rtmpPort;
|
|
52
|
+
private rtmpKey;
|
|
53
|
+
constructor(options: IngestOptions);
|
|
54
|
+
status(): IngestStatus;
|
|
55
|
+
get live(): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Open a session. Returns the session, or null when one is already running:
|
|
58
|
+
* the caller answers 409, because "somebody else is already broadcasting" is
|
|
59
|
+
* a different problem from "your request was wrong".
|
|
60
|
+
*/
|
|
61
|
+
open(name: string, format: string): IngestSession | null;
|
|
62
|
+
/** Feed it. Returns false once the session is over. */
|
|
63
|
+
write(chunk: Buffer): boolean;
|
|
64
|
+
/** Pipe a whole request body in, for a sender that can stream one. */
|
|
65
|
+
pump(body: Readable): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Wait for an RTMP publisher, and keep waiting after each one leaves.
|
|
68
|
+
*
|
|
69
|
+
* ffmpeg is the RTMP server here: `-rtmp_listen 1` binds the port and blocks
|
|
70
|
+
* until somebody publishes. It serves one publisher and exits, so the
|
|
71
|
+
* listener is started again afterwards -- otherwise a broadcaster who
|
|
72
|
+
* reconnects finds nothing listening.
|
|
73
|
+
*/
|
|
74
|
+
listenRtmp(port: number, key: string): void;
|
|
75
|
+
/** Stop waiting for publishers. */
|
|
76
|
+
stopRtmp(): void;
|
|
77
|
+
private armRtmp;
|
|
78
|
+
/** End the session, whoever ended it. */
|
|
79
|
+
close(error?: string): void;
|
|
80
|
+
}
|
package/dist/ingest.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming into a nixamp, rather than out of one.
|
|
3
|
+
*
|
|
4
|
+
* A phone or a laptop sends live audio to the server, and the server plays it,
|
|
5
|
+
* serves it to listeners, and broadcasts it onward. Two ways in, because one is
|
|
6
|
+
* not enough:
|
|
7
|
+
*
|
|
8
|
+
* - A single long POST whose body is the stream. ffmpeg, OBS, curl and the
|
|
9
|
+
* desktop app can all do this, and it is the efficient one.
|
|
10
|
+
* - A sequence of small POSTs, for browsers. Chrome only allows a streaming
|
|
11
|
+
* request body over HTTP/2, and a nixamp on your own network is plain
|
|
12
|
+
* HTTP/1.1, so a phone that wants to broadcast has to send chunks.
|
|
13
|
+
*
|
|
14
|
+
* Both end up writing into the same ffmpeg stdin, which is what makes the two
|
|
15
|
+
* paths interchangeable to everything downstream.
|
|
16
|
+
*
|
|
17
|
+
* And a third, which is the one anything native should use: RTMP. Point OBS,
|
|
18
|
+
* Larix or another ffmpeg at rtmp://this-machine/live/<key> and it publishes,
|
|
19
|
+
* with no nixamp-shaped client in the middle. ffmpeg listens for it (-rtmp_listen),
|
|
20
|
+
* so this costs no dependency and no protocol implementation. The HTTP paths
|
|
21
|
+
* above are not a workaround for that: they are what a *browser* has, since a
|
|
22
|
+
* web page cannot speak RTMP at all.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
/** Containers a browser or a desktop encoder actually produces. */
|
|
27
|
+
const FORMATS = new Set(["webm", "ogg", "mp4", "matroska", "mp3", "wav", "flv"]);
|
|
28
|
+
/**
|
|
29
|
+
* ffmpeg's `-f` is a demuxer name, and passing an unknown one is how a stream
|
|
30
|
+
* dies four seconds in with a message nobody sees. An unrecognised container is
|
|
31
|
+
* refused up front instead.
|
|
32
|
+
*/
|
|
33
|
+
export function normaliseFormat(value) {
|
|
34
|
+
if (typeof value !== "string")
|
|
35
|
+
return null;
|
|
36
|
+
const format = value.trim().toLowerCase();
|
|
37
|
+
if (format === "")
|
|
38
|
+
return null;
|
|
39
|
+
// A MediaRecorder mime type: audio/webm;codecs=opus.
|
|
40
|
+
// The subtype may carry a hyphen: audio/x-matroska is what some browsers
|
|
41
|
+
// hand out, and stopping at the hyphen turned it into "x".
|
|
42
|
+
const fromMime = /^(?:audio|video)\/([a-z0-9-]+)/.exec(format)?.[1];
|
|
43
|
+
const candidate = fromMime ?? format;
|
|
44
|
+
const mapped = candidate === "x-matroska" ? "matroska" : candidate;
|
|
45
|
+
return FORMATS.has(mapped) ? mapped : null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The live input.
|
|
49
|
+
*
|
|
50
|
+
* One session at a time: a second sender is refused rather than mixed, because
|
|
51
|
+
* mixing two uninvited streams is never what anybody meant.
|
|
52
|
+
*/
|
|
53
|
+
export class Ingest {
|
|
54
|
+
options;
|
|
55
|
+
child = null;
|
|
56
|
+
session = null;
|
|
57
|
+
closing = false;
|
|
58
|
+
/** The RTMP listener, which outlives any one publisher. */
|
|
59
|
+
rtmpChild = null;
|
|
60
|
+
rtmpPort = null;
|
|
61
|
+
rtmpKey = "";
|
|
62
|
+
constructor(options) {
|
|
63
|
+
this.options = options;
|
|
64
|
+
}
|
|
65
|
+
status() {
|
|
66
|
+
return {
|
|
67
|
+
live: this.session !== null,
|
|
68
|
+
session: this.session,
|
|
69
|
+
rtmp: this.rtmpPort === null ? null : { port: this.rtmpPort, path: `/live/${this.rtmpKey}` },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
get live() {
|
|
73
|
+
return this.session !== null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Open a session. Returns the session, or null when one is already running:
|
|
77
|
+
* the caller answers 409, because "somebody else is already broadcasting" is
|
|
78
|
+
* a different problem from "your request was wrong".
|
|
79
|
+
*/
|
|
80
|
+
open(name, format) {
|
|
81
|
+
if (this.session !== null)
|
|
82
|
+
return null;
|
|
83
|
+
const session = {
|
|
84
|
+
id: randomBytes(8).toString("hex"),
|
|
85
|
+
name: name || "a device",
|
|
86
|
+
format,
|
|
87
|
+
startedAt: Date.now(),
|
|
88
|
+
bytes: 0,
|
|
89
|
+
};
|
|
90
|
+
const [command, ...prefix] = this.options.ffmpeg;
|
|
91
|
+
const child = spawn(command, [
|
|
92
|
+
...prefix,
|
|
93
|
+
"-hide_banner",
|
|
94
|
+
"-loglevel", "error",
|
|
95
|
+
// The demuxer is stated because ffmpeg mis-probes a live, unseekable
|
|
96
|
+
// pipe: it reads a few kilobytes, guesses, and guesses wrong.
|
|
97
|
+
"-f", format,
|
|
98
|
+
"-i", "pipe:0",
|
|
99
|
+
"-vn",
|
|
100
|
+
"-c:a", "libmp3lame",
|
|
101
|
+
"-b:a", "192k",
|
|
102
|
+
"-y",
|
|
103
|
+
"-f", "mp3",
|
|
104
|
+
this.options.sink,
|
|
105
|
+
], { stdio: ["pipe", "ignore", "pipe"] });
|
|
106
|
+
let tail = "";
|
|
107
|
+
child.stderr?.on("data", (chunk) => {
|
|
108
|
+
tail = (tail + chunk.toString()).slice(-2000);
|
|
109
|
+
});
|
|
110
|
+
// A sender that hangs up mid-write breaks the pipe, and an unhandled EPIPE
|
|
111
|
+
// takes the whole server down with it.
|
|
112
|
+
child.stdin?.on("error", () => this.close(""));
|
|
113
|
+
child.on("error", (error) => this.close(error.message));
|
|
114
|
+
child.on("close", (code) => this.close(code === 0 ? "" : tail.trim().split("\n").pop() ?? ""));
|
|
115
|
+
this.child = child;
|
|
116
|
+
this.session = session;
|
|
117
|
+
this.closing = false;
|
|
118
|
+
this.options.onStart(session);
|
|
119
|
+
return session;
|
|
120
|
+
}
|
|
121
|
+
/** Feed it. Returns false once the session is over. */
|
|
122
|
+
write(chunk) {
|
|
123
|
+
const child = this.child;
|
|
124
|
+
const session = this.session;
|
|
125
|
+
if (child === null || session === null || child.stdin === null)
|
|
126
|
+
return false;
|
|
127
|
+
session.bytes += chunk.byteLength;
|
|
128
|
+
return child.stdin.write(chunk);
|
|
129
|
+
}
|
|
130
|
+
/** Pipe a whole request body in, for a sender that can stream one. */
|
|
131
|
+
async pump(body) {
|
|
132
|
+
for await (const chunk of body) {
|
|
133
|
+
if (!this.write(chunk)) {
|
|
134
|
+
// Backpressure: wait for the drain rather than growing a buffer that
|
|
135
|
+
// is really the network's problem.
|
|
136
|
+
await new Promise((done) => this.child?.stdin?.once("drain", done) ?? done(null));
|
|
137
|
+
}
|
|
138
|
+
if (this.session === null)
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Wait for an RTMP publisher, and keep waiting after each one leaves.
|
|
144
|
+
*
|
|
145
|
+
* ffmpeg is the RTMP server here: `-rtmp_listen 1` binds the port and blocks
|
|
146
|
+
* until somebody publishes. It serves one publisher and exits, so the
|
|
147
|
+
* listener is started again afterwards -- otherwise a broadcaster who
|
|
148
|
+
* reconnects finds nothing listening.
|
|
149
|
+
*/
|
|
150
|
+
listenRtmp(port, key) {
|
|
151
|
+
this.rtmpPort = port;
|
|
152
|
+
this.rtmpKey = key;
|
|
153
|
+
this.armRtmp();
|
|
154
|
+
}
|
|
155
|
+
/** Stop waiting for publishers. */
|
|
156
|
+
stopRtmp() {
|
|
157
|
+
this.rtmpPort = null;
|
|
158
|
+
const listener = this.rtmpChild;
|
|
159
|
+
this.rtmpChild = null;
|
|
160
|
+
listener?.kill("SIGKILL");
|
|
161
|
+
}
|
|
162
|
+
armRtmp() {
|
|
163
|
+
const port = this.rtmpPort;
|
|
164
|
+
if (port === null || this.rtmpChild !== null)
|
|
165
|
+
return;
|
|
166
|
+
const [command, ...prefix] = this.options.ffmpeg;
|
|
167
|
+
const child = spawn(command, [
|
|
168
|
+
...prefix,
|
|
169
|
+
"-hide_banner",
|
|
170
|
+
"-loglevel", "error",
|
|
171
|
+
"-rtmp_listen", "1",
|
|
172
|
+
// Wait indefinitely: a stream that starts tomorrow is still the stream.
|
|
173
|
+
"-timeout", "-1",
|
|
174
|
+
"-f", "flv",
|
|
175
|
+
"-i", `rtmp://0.0.0.0:${port}/live/${this.rtmpKey}`,
|
|
176
|
+
"-vn",
|
|
177
|
+
"-c:a", "libmp3lame",
|
|
178
|
+
"-b:a", "192k",
|
|
179
|
+
"-y",
|
|
180
|
+
"-f", "mp3",
|
|
181
|
+
this.options.sink,
|
|
182
|
+
],
|
|
183
|
+
// stdout is watched rather than ignored: the encoded bytes are the only
|
|
184
|
+
// honest signal that a publisher turned up. ffmpeg does not announce a
|
|
185
|
+
// connect, a healthy stream says nothing at -loglevel error, and
|
|
186
|
+
// -progress only reports at the end in this build. Audio existing means
|
|
187
|
+
// audio arrived.
|
|
188
|
+
{ stdio: ["ignore", "pipe", "pipe"] });
|
|
189
|
+
const session = {
|
|
190
|
+
id: randomBytes(8).toString("hex"),
|
|
191
|
+
name: "an RTMP publisher",
|
|
192
|
+
format: "flv",
|
|
193
|
+
startedAt: Date.now(),
|
|
194
|
+
bytes: 0,
|
|
195
|
+
};
|
|
196
|
+
let tail = "";
|
|
197
|
+
child.stderr?.on("data", (chunk) => {
|
|
198
|
+
tail = (tail + chunk.toString()).slice(-2000);
|
|
199
|
+
});
|
|
200
|
+
child.stdout?.on("data", (chunk) => {
|
|
201
|
+
if (this.rtmpChild !== child)
|
|
202
|
+
return;
|
|
203
|
+
if (this.session === null) {
|
|
204
|
+
this.session = session;
|
|
205
|
+
this.options.onStart(session);
|
|
206
|
+
}
|
|
207
|
+
if (this.session === session)
|
|
208
|
+
session.bytes += chunk.byteLength;
|
|
209
|
+
// Consumed and dropped for now. The pipe has to be read either way --
|
|
210
|
+
// an unread one fills and stalls the encoder -- and handing these bytes
|
|
211
|
+
// to the listeners is the next piece of work, not a missing one here.
|
|
212
|
+
this.options.onAudio?.(chunk);
|
|
213
|
+
});
|
|
214
|
+
child.on("error", () => {
|
|
215
|
+
if (this.rtmpChild === child)
|
|
216
|
+
this.rtmpChild = null;
|
|
217
|
+
});
|
|
218
|
+
child.on("close", () => {
|
|
219
|
+
if (this.rtmpChild !== child)
|
|
220
|
+
return;
|
|
221
|
+
this.rtmpChild = null;
|
|
222
|
+
if (this.session === session) {
|
|
223
|
+
this.session = null;
|
|
224
|
+
// A publisher disconnecting is how a broadcast ends, not an error.
|
|
225
|
+
this.options.onEnd(session, "");
|
|
226
|
+
}
|
|
227
|
+
// Listen again for the next one.
|
|
228
|
+
if (this.rtmpPort !== null)
|
|
229
|
+
this.armRtmp();
|
|
230
|
+
});
|
|
231
|
+
this.rtmpChild = child;
|
|
232
|
+
}
|
|
233
|
+
/** End the session, whoever ended it. */
|
|
234
|
+
close(error = "") {
|
|
235
|
+
if (this.closing)
|
|
236
|
+
return;
|
|
237
|
+
this.closing = true;
|
|
238
|
+
const session = this.session;
|
|
239
|
+
const child = this.child;
|
|
240
|
+
this.session = null;
|
|
241
|
+
this.child = null;
|
|
242
|
+
try {
|
|
243
|
+
child?.stdin?.end();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Already broken, which is usually why we are here.
|
|
247
|
+
}
|
|
248
|
+
child?.kill("SIGKILL");
|
|
249
|
+
if (session)
|
|
250
|
+
this.options.onEnd(session, error);
|
|
251
|
+
}
|
|
252
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -47,6 +47,8 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
47
47
|
nixamp serve [source] [options] play here, and hand out a browser remote
|
|
48
48
|
nixamp daemon start|stop|status serve in the background, and let go of it
|
|
49
49
|
nixamp admin [--url U] [--key K] who is connected, and re-stream to them
|
|
50
|
+
nixamp login [--signup] sign in to nixamp.com
|
|
51
|
+
nixamp logout / whoami forget it, or check it
|
|
50
52
|
nixamp update [version] re-run the installer, keeping your choices
|
|
51
53
|
nixamp uninstall [--yes] remove everything the installer created
|
|
52
54
|
|
|
@@ -60,6 +62,15 @@ Options for serve:
|
|
|
60
62
|
--no-media do not stream the library's bytes to remotes
|
|
61
63
|
--no-key serve to anyone who can reach the port, with no share link
|
|
62
64
|
--open-port let the port through the local firewall, and close it on exit
|
|
65
|
+
--publish list it at nixamp.com/directory without asking first
|
|
66
|
+
--no-publish never list it, and do not ask
|
|
67
|
+
--name NAME what to call it in the directory (default: this hostname)
|
|
68
|
+
--ingest accept a live stream in at POST /api/ingest
|
|
69
|
+
--rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
|
|
70
|
+
--rtmp-streams N how many may publish at once (default 3, a port each)
|
|
71
|
+
--rtmp D broadcast out, e.g. --rtmp youtube=<key>. Repeatable
|
|
72
|
+
--x402 charge for listening once more than 5 people are listening
|
|
73
|
+
--no-x402 never charge
|
|
63
74
|
|
|
64
75
|
-v, --version print the version
|
|
65
76
|
--help print this
|
|
@@ -140,6 +151,16 @@ export async function main() {
|
|
|
140
151
|
await admin(rest);
|
|
141
152
|
return;
|
|
142
153
|
}
|
|
154
|
+
if (first === "login" || first === "signup") {
|
|
155
|
+
const { login } = await import("./session.js");
|
|
156
|
+
process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (first === "logout" || first === "whoami") {
|
|
160
|
+
const session = await import("./session.js");
|
|
161
|
+
process.exitCode = first === "logout" ? session.logout() : await session.whoami();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
143
164
|
if (first === "update" || first === "uninstall") {
|
|
144
165
|
const manage = await import("./manage.js");
|
|
145
166
|
process.exitCode = first === "update" ? manage.update(rest) : manage.uninstall(rest);
|
package/dist/manage.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
12
13
|
const SITE = "https://nixamp.com";
|
|
13
14
|
/** Windows has its own installer, its own shim and its own removal script. */
|
|
14
15
|
const windows = process.platform === "win32";
|
|
@@ -17,7 +18,7 @@ const windows = process.platform === "win32";
|
|
|
17
18
|
* wrote, which is the only thing that knows for certain; the walk up from this
|
|
18
19
|
* file covers a shim from an older install that did not set it.
|
|
19
20
|
*/
|
|
20
|
-
export function installRoot(from = new URL(".", import.meta.url)
|
|
21
|
+
export function installRoot(from = fileURLToPath(new URL(".", import.meta.url))) {
|
|
21
22
|
const declared = process.env["NIXAMP_HOME"];
|
|
22
23
|
if (declared && existsSync(join(declared, "manifest.json")))
|
|
23
24
|
return declared;
|