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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
/** Somewhere for a channel's audio to go. A response, in practice. */
|
|
3
|
+
export interface Listener {
|
|
4
|
+
write(chunk: Buffer): boolean;
|
|
5
|
+
end(): void;
|
|
6
|
+
}
|
|
7
|
+
export interface ChannelInfo {
|
|
8
|
+
id: string;
|
|
9
|
+
/** What the publisher called itself. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** The container it is sending, e.g. webm from a browser, flv over RTMP. */
|
|
12
|
+
format: string;
|
|
13
|
+
/** How it arrived. */
|
|
14
|
+
via: "http" | "rtmp";
|
|
15
|
+
startedAt: number;
|
|
16
|
+
bytes: number;
|
|
17
|
+
listeners: number;
|
|
18
|
+
}
|
|
19
|
+
/** A name that can sit in a URL and be read back in a list. */
|
|
20
|
+
export declare function cleanId(value: unknown, fallback?: string): string;
|
|
21
|
+
export interface ChannelOptions {
|
|
22
|
+
ffmpeg: string[];
|
|
23
|
+
onStart?: (info: ChannelInfo) => void;
|
|
24
|
+
onEnd?: (info: ChannelInfo) => void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One live source, and its audience.
|
|
28
|
+
*
|
|
29
|
+
* Everything a listener is sent has been through ffmpeg, so a publisher cannot
|
|
30
|
+
* decide what bytes reach a browser by choosing what to send.
|
|
31
|
+
*/
|
|
32
|
+
export declare class Channel {
|
|
33
|
+
readonly info: ChannelInfo;
|
|
34
|
+
private readonly options;
|
|
35
|
+
private readonly onGone;
|
|
36
|
+
readonly listeners: Set<Listener>;
|
|
37
|
+
private child;
|
|
38
|
+
private closing;
|
|
39
|
+
constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
|
|
40
|
+
start(format: string): void;
|
|
41
|
+
/** Feed the source. */
|
|
42
|
+
write(chunk: Buffer): boolean;
|
|
43
|
+
pump(body: Readable): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Audio that is already in its final form, from a source we did not spawn.
|
|
46
|
+
* The bytes still only reach a listener after something decoded them; it was
|
|
47
|
+
* simply a different process that did it.
|
|
48
|
+
*/
|
|
49
|
+
feed(chunk: Buffer): void;
|
|
50
|
+
/** Write to everyone, and drop anybody whose socket has gone. */
|
|
51
|
+
private send;
|
|
52
|
+
listen(listener: Listener): () => void;
|
|
53
|
+
close(): void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Every channel currently live.
|
|
57
|
+
*
|
|
58
|
+
* A channel exists while somebody is publishing to it and disappears when they
|
|
59
|
+
* stop, so the list is what is actually on rather than what was once
|
|
60
|
+
* configured.
|
|
61
|
+
*/
|
|
62
|
+
export declare class Channels {
|
|
63
|
+
private readonly options;
|
|
64
|
+
private readonly open;
|
|
65
|
+
constructor(options: ChannelOptions);
|
|
66
|
+
list(): ChannelInfo[];
|
|
67
|
+
get count(): number;
|
|
68
|
+
/** Total listeners across every channel. */
|
|
69
|
+
get listeners(): number;
|
|
70
|
+
has(id: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Claim a channel and start decoding into it. Null when that channel is
|
|
73
|
+
* already being published to: two publishers on one channel would be two
|
|
74
|
+
* songs at once, which is never what anybody meant. Publishing to a
|
|
75
|
+
* *different* channel is exactly what this class exists for.
|
|
76
|
+
*/
|
|
77
|
+
publish(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null;
|
|
78
|
+
/** Attach a listener, or null when nothing is playing on that channel. */
|
|
79
|
+
listen(id: string, listener: Listener): (() => void) | null;
|
|
80
|
+
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
81
|
+
writeTo(id: string, chunk: Buffer): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* A channel fed by audio somebody else is already decoding.
|
|
84
|
+
*
|
|
85
|
+
* An RTMP listener is an ffmpeg with a publisher on one end, and it produces
|
|
86
|
+
* MP3 on its own. Spawning a second ffmpeg to decode what the first one just
|
|
87
|
+
* decoded would double the work to arrive at the same bytes.
|
|
88
|
+
*/
|
|
89
|
+
attach(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null;
|
|
90
|
+
stop(id: string): boolean;
|
|
91
|
+
stopAll(): void;
|
|
92
|
+
}
|
|
93
|
+
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
94
|
+
export declare function generatedId(): string;
|
package/dist/channels.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Several streams at once.
|
|
3
|
+
*
|
|
4
|
+
* A channel is one live source and everybody listening to it. Two or three
|
|
5
|
+
* devices can publish at the same time -- a phone, a desktop, a second window
|
|
6
|
+
* -- and each has its own audience, so a listener picks which one to hear.
|
|
7
|
+
*
|
|
8
|
+
* The fan-out is the point. One ffmpeg decodes a publisher's bytes once, and
|
|
9
|
+
* the MP3 it produces is written to every listener attached to that channel.
|
|
10
|
+
* A decode per listener would cost a CPU core each and, for a live stream,
|
|
11
|
+
* would not even agree with itself about what "now" is.
|
|
12
|
+
*
|
|
13
|
+
* A listener joining halfway through gets the stream from that moment, which is
|
|
14
|
+
* what live means. MP3 frames are self-describing, so a player finds the next
|
|
15
|
+
* frame boundary and carries on; there is nothing to catch up on.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { randomBytes } from "node:crypto";
|
|
19
|
+
/** A name that can sit in a URL and be read back in a list. */
|
|
20
|
+
export function cleanId(value, fallback = "main") {
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
return fallback;
|
|
23
|
+
const id = value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
24
|
+
return id.slice(0, 40) || fallback;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One live source, and its audience.
|
|
28
|
+
*
|
|
29
|
+
* Everything a listener is sent has been through ffmpeg, so a publisher cannot
|
|
30
|
+
* decide what bytes reach a browser by choosing what to send.
|
|
31
|
+
*/
|
|
32
|
+
export class Channel {
|
|
33
|
+
info;
|
|
34
|
+
options;
|
|
35
|
+
onGone;
|
|
36
|
+
listeners = new Set();
|
|
37
|
+
child = null;
|
|
38
|
+
closing = false;
|
|
39
|
+
constructor(info, options, onGone) {
|
|
40
|
+
this.info = info;
|
|
41
|
+
this.options = options;
|
|
42
|
+
this.onGone = onGone;
|
|
43
|
+
}
|
|
44
|
+
start(format) {
|
|
45
|
+
const [command, ...prefix] = this.options.ffmpeg;
|
|
46
|
+
const child = spawn(command, [
|
|
47
|
+
...prefix,
|
|
48
|
+
"-hide_banner",
|
|
49
|
+
"-loglevel", "error",
|
|
50
|
+
// Stated, because ffmpeg mis-probes a live unseekable pipe: it reads a
|
|
51
|
+
// few kilobytes, guesses, and guesses wrong.
|
|
52
|
+
"-f", format,
|
|
53
|
+
"-i", "pipe:0",
|
|
54
|
+
"-vn",
|
|
55
|
+
"-c:a", "libmp3lame",
|
|
56
|
+
"-b:a", "192k",
|
|
57
|
+
"-f", "mp3",
|
|
58
|
+
"pipe:1",
|
|
59
|
+
], { stdio: ["pipe", "pipe", "pipe"] });
|
|
60
|
+
child.stdout?.on("data", (chunk) => {
|
|
61
|
+
this.info.bytes += chunk.byteLength;
|
|
62
|
+
this.send(chunk);
|
|
63
|
+
});
|
|
64
|
+
// A publisher that hangs up mid-write breaks the pipe, and an unhandled
|
|
65
|
+
// EPIPE takes the whole server with it.
|
|
66
|
+
child.stdin?.on("error", () => this.close());
|
|
67
|
+
child.stdout?.on("error", () => this.close());
|
|
68
|
+
child.on("error", () => this.close());
|
|
69
|
+
child.on("close", () => this.close());
|
|
70
|
+
this.child = child;
|
|
71
|
+
this.options.onStart?.(this.info);
|
|
72
|
+
}
|
|
73
|
+
/** Feed the source. */
|
|
74
|
+
write(chunk) {
|
|
75
|
+
return this.child?.stdin?.write(chunk) ?? false;
|
|
76
|
+
}
|
|
77
|
+
async pump(body) {
|
|
78
|
+
for await (const chunk of body) {
|
|
79
|
+
if (this.closing)
|
|
80
|
+
return;
|
|
81
|
+
if (!this.write(chunk)) {
|
|
82
|
+
await new Promise((done) => this.child?.stdin?.once("drain", done) ?? done(null));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Audio that is already in its final form, from a source we did not spawn.
|
|
88
|
+
* The bytes still only reach a listener after something decoded them; it was
|
|
89
|
+
* simply a different process that did it.
|
|
90
|
+
*/
|
|
91
|
+
feed(chunk) {
|
|
92
|
+
this.info.bytes += chunk.byteLength;
|
|
93
|
+
this.send(chunk);
|
|
94
|
+
}
|
|
95
|
+
/** Write to everyone, and drop anybody whose socket has gone. */
|
|
96
|
+
send(chunk) {
|
|
97
|
+
for (const listener of this.listeners) {
|
|
98
|
+
try {
|
|
99
|
+
listener.write(chunk);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// One listener's broken socket is not the channel's problem.
|
|
103
|
+
this.listeners.delete(listener);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this.info.listeners = this.listeners.size;
|
|
107
|
+
}
|
|
108
|
+
listen(listener) {
|
|
109
|
+
this.listeners.add(listener);
|
|
110
|
+
this.info.listeners = this.listeners.size;
|
|
111
|
+
return () => {
|
|
112
|
+
this.listeners.delete(listener);
|
|
113
|
+
this.info.listeners = this.listeners.size;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
close() {
|
|
117
|
+
if (this.closing)
|
|
118
|
+
return;
|
|
119
|
+
this.closing = true;
|
|
120
|
+
const child = this.child;
|
|
121
|
+
this.child = null;
|
|
122
|
+
try {
|
|
123
|
+
child?.stdin?.end();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Already broken, which is usually why we are here.
|
|
127
|
+
}
|
|
128
|
+
child?.kill("SIGKILL");
|
|
129
|
+
// Listeners are ended rather than left hanging on a stream that stopped.
|
|
130
|
+
for (const listener of this.listeners) {
|
|
131
|
+
try {
|
|
132
|
+
listener.end();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Gone already.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
this.listeners.clear();
|
|
139
|
+
this.info.listeners = 0;
|
|
140
|
+
this.options.onEnd?.(this.info);
|
|
141
|
+
this.onGone(this.info.id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Every channel currently live.
|
|
146
|
+
*
|
|
147
|
+
* A channel exists while somebody is publishing to it and disappears when they
|
|
148
|
+
* stop, so the list is what is actually on rather than what was once
|
|
149
|
+
* configured.
|
|
150
|
+
*/
|
|
151
|
+
export class Channels {
|
|
152
|
+
options;
|
|
153
|
+
open = new Map();
|
|
154
|
+
constructor(options) {
|
|
155
|
+
this.options = options;
|
|
156
|
+
}
|
|
157
|
+
list() {
|
|
158
|
+
return [...this.open.values()]
|
|
159
|
+
.map((channel) => channel.info)
|
|
160
|
+
.sort((a, b) => a.startedAt - b.startedAt);
|
|
161
|
+
}
|
|
162
|
+
get count() {
|
|
163
|
+
return this.open.size;
|
|
164
|
+
}
|
|
165
|
+
/** Total listeners across every channel. */
|
|
166
|
+
get listeners() {
|
|
167
|
+
let total = 0;
|
|
168
|
+
for (const channel of this.open.values())
|
|
169
|
+
total += channel.listeners.size;
|
|
170
|
+
return total;
|
|
171
|
+
}
|
|
172
|
+
has(id) {
|
|
173
|
+
return this.open.has(id);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Claim a channel and start decoding into it. Null when that channel is
|
|
177
|
+
* already being published to: two publishers on one channel would be two
|
|
178
|
+
* songs at once, which is never what anybody meant. Publishing to a
|
|
179
|
+
* *different* channel is exactly what this class exists for.
|
|
180
|
+
*/
|
|
181
|
+
publish(id, name, format, via) {
|
|
182
|
+
if (this.open.has(id))
|
|
183
|
+
return null;
|
|
184
|
+
const channel = new Channel({
|
|
185
|
+
id,
|
|
186
|
+
name: name || "a device",
|
|
187
|
+
format,
|
|
188
|
+
via,
|
|
189
|
+
startedAt: Date.now(),
|
|
190
|
+
bytes: 0,
|
|
191
|
+
listeners: 0,
|
|
192
|
+
}, this.options, (gone) => this.open.delete(gone));
|
|
193
|
+
this.open.set(id, channel);
|
|
194
|
+
channel.start(format);
|
|
195
|
+
return channel;
|
|
196
|
+
}
|
|
197
|
+
/** Attach a listener, or null when nothing is playing on that channel. */
|
|
198
|
+
listen(id, listener) {
|
|
199
|
+
const channel = this.open.get(id);
|
|
200
|
+
return channel ? channel.listen(listener) : null;
|
|
201
|
+
}
|
|
202
|
+
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
203
|
+
writeTo(id, chunk) {
|
|
204
|
+
return this.open.get(id)?.write(chunk) ?? false;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A channel fed by audio somebody else is already decoding.
|
|
208
|
+
*
|
|
209
|
+
* An RTMP listener is an ffmpeg with a publisher on one end, and it produces
|
|
210
|
+
* MP3 on its own. Spawning a second ffmpeg to decode what the first one just
|
|
211
|
+
* decoded would double the work to arrive at the same bytes.
|
|
212
|
+
*/
|
|
213
|
+
attach(id, name, format, via) {
|
|
214
|
+
if (this.open.has(id))
|
|
215
|
+
return null;
|
|
216
|
+
const channel = new Channel({ id, name: name || "a device", format, via, startedAt: Date.now(), bytes: 0, listeners: 0 }, this.options, (gone) => this.open.delete(gone));
|
|
217
|
+
this.open.set(id, channel);
|
|
218
|
+
return channel;
|
|
219
|
+
}
|
|
220
|
+
stop(id) {
|
|
221
|
+
const channel = this.open.get(id);
|
|
222
|
+
if (!channel)
|
|
223
|
+
return false;
|
|
224
|
+
channel.close();
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
stopAll() {
|
|
228
|
+
for (const channel of [...this.open.values()])
|
|
229
|
+
channel.close();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
233
|
+
export function generatedId() {
|
|
234
|
+
return `s${randomBytes(3).toString("hex")}`;
|
|
235
|
+
}
|
package/dist/connections.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export declare class Connections {
|
|
|
60
60
|
* whatever the sort happened to do.
|
|
61
61
|
*/
|
|
62
62
|
list(): Connection[];
|
|
63
|
+
/**
|
|
64
|
+
* Live connections that are actually hearing something. The state feed and
|
|
65
|
+
* the page are not listeners, and counting them would put a stream over the
|
|
66
|
+
* free allowance with nobody listening to it.
|
|
67
|
+
*/
|
|
68
|
+
get listening(): number;
|
|
63
69
|
get active(): number;
|
|
64
70
|
/** Drop the oldest finished entries once there are more than we keep. */
|
|
65
71
|
private prune;
|
package/dist/connections.js
CHANGED
|
@@ -97,6 +97,19 @@ export class Connections {
|
|
|
97
97
|
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || b.id - a.id);
|
|
98
98
|
return [...live, ...done];
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Live connections that are actually hearing something. The state feed and
|
|
102
|
+
* the page are not listeners, and counting them would put a stream over the
|
|
103
|
+
* free allowance with nobody listening to it.
|
|
104
|
+
*/
|
|
105
|
+
get listening() {
|
|
106
|
+
let count = 0;
|
|
107
|
+
for (const item of this.items.values()) {
|
|
108
|
+
if (item.endedAt === null && (item.kind === "stream" || item.kind === "media"))
|
|
109
|
+
count++;
|
|
110
|
+
}
|
|
111
|
+
return count;
|
|
112
|
+
}
|
|
100
113
|
get active() {
|
|
101
114
|
let count = 0;
|
|
102
115
|
for (const item of this.items.values())
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public directory.
|
|
3
|
+
*
|
|
4
|
+
* A nixamp that agrees to be listed announces itself to nixamp.com every so
|
|
5
|
+
* often and is forgotten when it stops. There is no database behind it: an
|
|
6
|
+
* entry lives for a few minutes and a heartbeat renews it, so a restart of the
|
|
7
|
+
* directory costs one heartbeat rather than a migration, and a stream that
|
|
8
|
+
* dies falls out of the list without anyone having to notice.
|
|
9
|
+
*
|
|
10
|
+
* What is published is the *listen* link. The control key never leaves the
|
|
11
|
+
* machine it was minted on.
|
|
12
|
+
*/
|
|
13
|
+
/** How long an entry survives without a heartbeat. */
|
|
14
|
+
export declare const TTL_MS: number;
|
|
15
|
+
/** How often a publisher renews. Comfortably inside the TTL. */
|
|
16
|
+
export declare const HEARTBEAT_MS: number;
|
|
17
|
+
export declare const DEFAULT_DIRECTORY = "https://nixamp.com";
|
|
18
|
+
export interface Listing {
|
|
19
|
+
/** Assigned by the directory, so a publisher cannot claim someone else's. */
|
|
20
|
+
id: string;
|
|
21
|
+
/**
|
|
22
|
+
* A six-digit code for this stream, stable across the whole run.
|
|
23
|
+
*
|
|
24
|
+
* This is what somebody keys into the phone line. It has to be short enough
|
|
25
|
+
* to read out and survive being remembered, which the id is not.
|
|
26
|
+
*/
|
|
27
|
+
code: string;
|
|
28
|
+
name: string;
|
|
29
|
+
/**
|
|
30
|
+
* The account that announced it.
|
|
31
|
+
*
|
|
32
|
+
* Set from the signed-in publisher, never from the announcement body -- a
|
|
33
|
+
* stream that could name its own owner could name somebody else's, and
|
|
34
|
+
* followers would be told about a broadcast that person is not making.
|
|
35
|
+
*/
|
|
36
|
+
ownerId: string;
|
|
37
|
+
/** The listen link, which is what a browser opens. */
|
|
38
|
+
url: string;
|
|
39
|
+
/**
|
|
40
|
+
* The same stream as bytes, for something that is not a browser.
|
|
41
|
+
*
|
|
42
|
+
* `url` is a share link: it answers 302, sets a cookie and redirects to the
|
|
43
|
+
* player page. That is exactly right for a person and useless to anything
|
|
44
|
+
* that cannot hold a cookie -- the phone line hands this address to Telnyx
|
|
45
|
+
* to play into a call, and Telnyx fetches it once, anonymously, and expects
|
|
46
|
+
* audio back. Handed the share link it gets a 401 in JSON and the caller
|
|
47
|
+
* hears silence after being told the stream is about to start.
|
|
48
|
+
*
|
|
49
|
+
* So a publisher announces both: the link a person opens, and the address
|
|
50
|
+
* that answers with audio/mpeg to a plain GET. Empty when the publisher is
|
|
51
|
+
* an older nixamp that only knows about `url`.
|
|
52
|
+
*/
|
|
53
|
+
audio: string;
|
|
54
|
+
tracks: number;
|
|
55
|
+
nowPlaying: string;
|
|
56
|
+
/** Set by the directory from the request, never by the publisher. */
|
|
57
|
+
updatedAt: number;
|
|
58
|
+
/** When this stream first announced itself: the "started at" a caller hears. */
|
|
59
|
+
startedAt: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A stream that has stopped, kept for a while after it fell out of the list.
|
|
63
|
+
*
|
|
64
|
+
* The directory proper forgets a stream the moment it stops renewing, which is
|
|
65
|
+
* right for a list of what is on -- but it means there is nobody left to say
|
|
66
|
+
* *when* it ended, and "call back later" with no time in it is not worth
|
|
67
|
+
* saying. So an ended stream leaves this behind: enough to answer the phone
|
|
68
|
+
* truthfully, and nothing anybody could listen to.
|
|
69
|
+
*/
|
|
70
|
+
export interface Ended {
|
|
71
|
+
id: string;
|
|
72
|
+
code: string;
|
|
73
|
+
name: string;
|
|
74
|
+
ownerId: string;
|
|
75
|
+
/** Kept so a stream returning on the same url is recognised as the same one. */
|
|
76
|
+
url: string;
|
|
77
|
+
nowPlaying: string;
|
|
78
|
+
startedAt: number;
|
|
79
|
+
/** The last heartbeat we saw, which is as close to "ended" as we can know. */
|
|
80
|
+
endedAt: number;
|
|
81
|
+
}
|
|
82
|
+
/** How long an ended stream is still worth telling a caller about. */
|
|
83
|
+
export declare const ENDED_TTL_MS: number;
|
|
84
|
+
/** What a publisher sends. Everything else about a listing is ours to decide. */
|
|
85
|
+
export interface Announcement {
|
|
86
|
+
id?: string;
|
|
87
|
+
name: string;
|
|
88
|
+
url: string;
|
|
89
|
+
/** Where the audio actually is. See `Listing.audio`. */
|
|
90
|
+
audio?: string;
|
|
91
|
+
tracks: number;
|
|
92
|
+
nowPlaying: string;
|
|
93
|
+
}
|
|
94
|
+
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
95
|
+
export declare function clean(value: unknown, max: number): string;
|
|
96
|
+
/**
|
|
97
|
+
* A URL we are willing to list. It has to be somewhere a browser can go, and
|
|
98
|
+
* it must not be a loopback or link-local address: those are only reachable
|
|
99
|
+
* from the machine that published them, so listing one is an entry nobody but
|
|
100
|
+
* the publisher can ever open.
|
|
101
|
+
*/
|
|
102
|
+
export declare function publishable(raw: string): URL | null;
|
|
103
|
+
export declare function parseAnnouncement(input: unknown): Announcement | null;
|
|
104
|
+
/**
|
|
105
|
+
* The registry. In memory on purpose: see the note at the top of the file.
|
|
106
|
+
* One entry per URL, so a publisher restarting does not leave a ghost of
|
|
107
|
+
* itself behind next to the entry that replaced it.
|
|
108
|
+
*/
|
|
109
|
+
export declare class Directory {
|
|
110
|
+
private readonly ttl;
|
|
111
|
+
private readonly now;
|
|
112
|
+
/** Injected so a test can make a code predictable rather than guess it. */
|
|
113
|
+
private readonly randomCode;
|
|
114
|
+
/**
|
|
115
|
+
* Called when a stream starts, and only then.
|
|
116
|
+
*
|
|
117
|
+
* A publisher announces every ninety seconds for as long as it is up, so
|
|
118
|
+
* "announced" is not "went live" -- telling followers on every heartbeat
|
|
119
|
+
* would be telling them forty times an hour. This fires on the transition
|
|
120
|
+
* and not on the renewals that follow it.
|
|
121
|
+
*/
|
|
122
|
+
private readonly onLive;
|
|
123
|
+
private readonly items;
|
|
124
|
+
/** Streams that stopped, so the phone line can say when. */
|
|
125
|
+
private readonly ended;
|
|
126
|
+
private sequence;
|
|
127
|
+
/**
|
|
128
|
+
* Somewhere to echo the ended list, so it survives a restart.
|
|
129
|
+
*
|
|
130
|
+
* Attached after construction rather than taken as a constructor argument:
|
|
131
|
+
* this is a mirror, not a dependency, and the directory works exactly as it
|
|
132
|
+
* did without one.
|
|
133
|
+
*/
|
|
134
|
+
private mirror;
|
|
135
|
+
/** Start echoing ended streams somewhere durable. */
|
|
136
|
+
persistTo(mirror: {
|
|
137
|
+
save: (item: Ended) => void;
|
|
138
|
+
drop: (id: string) => void;
|
|
139
|
+
}): void;
|
|
140
|
+
/**
|
|
141
|
+
* Put back what a previous process knew.
|
|
142
|
+
*
|
|
143
|
+
* Only fills gaps: anything already here was announced since we started and
|
|
144
|
+
* is newer than a row written before the restart.
|
|
145
|
+
*/
|
|
146
|
+
seedEnded(items: readonly Ended[]): void;
|
|
147
|
+
constructor(ttl?: number, now?: () => number,
|
|
148
|
+
/** Injected so a test can make a code predictable rather than guess it. */
|
|
149
|
+
randomCode?: () => string,
|
|
150
|
+
/**
|
|
151
|
+
* Called when a stream starts, and only then.
|
|
152
|
+
*
|
|
153
|
+
* A publisher announces every ninety seconds for as long as it is up, so
|
|
154
|
+
* "announced" is not "went live" -- telling followers on every heartbeat
|
|
155
|
+
* would be telling them forty times an hour. This fires on the transition
|
|
156
|
+
* and not on the renewals that follow it.
|
|
157
|
+
*/
|
|
158
|
+
onLive?: (listing: Listing) => void);
|
|
159
|
+
announce(announcement: Announcement, ownerId?: string): Listing;
|
|
160
|
+
withdraw(id: string): void;
|
|
161
|
+
list(): Listing[];
|
|
162
|
+
/** The live stream on this code, if there is one. */
|
|
163
|
+
liveByCode(code: string): Listing | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* Streams that stopped recently, most recent first.
|
|
166
|
+
*
|
|
167
|
+
* Kept for the phone line, which has to say when a stream ended -- but they
|
|
168
|
+
* answer a second question the live list cannot: who is there to follow.
|
|
169
|
+
* Following exists to hear about broadcasts you would otherwise miss, and a
|
|
170
|
+
* directory that only lists what is on can only be used to follow somebody
|
|
171
|
+
* during a broadcast you did not miss.
|
|
172
|
+
*/
|
|
173
|
+
recentlyEnded(): Ended[];
|
|
174
|
+
/** The name last used by an account, live or recently ended. */
|
|
175
|
+
nameOf(ownerId: string): string;
|
|
176
|
+
/** Whether this account is streaming right now. */
|
|
177
|
+
isLive(ownerId: string): boolean;
|
|
178
|
+
/** The stream that used to be on this code, if it stopped recently. */
|
|
179
|
+
endedByCode(code: string): Ended | undefined;
|
|
180
|
+
private endedByUrl;
|
|
181
|
+
private remember;
|
|
182
|
+
/** A code no live and no recently-ended stream is using. */
|
|
183
|
+
private freeCode;
|
|
184
|
+
/** Forget anything that stopped renewing, keeping a note of when it did. */
|
|
185
|
+
private sweep;
|
|
186
|
+
}
|