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.
Files changed (69) hide show
  1. package/README.md +171 -0
  2. package/dist/accounts.d.ts +54 -0
  3. package/dist/accounts.js +160 -0
  4. package/dist/broadcast.d.ts +96 -0
  5. package/dist/broadcast.js +193 -0
  6. package/dist/channels.d.ts +94 -0
  7. package/dist/channels.js +235 -0
  8. package/dist/connections.d.ts +6 -0
  9. package/dist/connections.js +13 -0
  10. package/dist/directory.d.ts +186 -0
  11. package/dist/directory.js +275 -0
  12. package/dist/durable.d.ts +70 -0
  13. package/dist/durable.js +156 -0
  14. package/dist/follows.d.ts +92 -0
  15. package/dist/follows.js +248 -0
  16. package/dist/ingest.d.ts +80 -0
  17. package/dist/ingest.js +252 -0
  18. package/dist/main.js +21 -0
  19. package/dist/manage.js +2 -1
  20. package/dist/notify.d.ts +83 -0
  21. package/dist/notify.js +126 -0
  22. package/dist/optin.d.ts +37 -0
  23. package/dist/optin.js +122 -0
  24. package/dist/owner.d.ts +53 -0
  25. package/dist/owner.js +96 -0
  26. package/dist/partyline.d.ts +259 -0
  27. package/dist/partyline.js +616 -0
  28. package/dist/paywall.d.ts +60 -0
  29. package/dist/paywall.js +162 -0
  30. package/dist/playlist.js +5 -0
  31. package/dist/publish.d.ts +57 -0
  32. package/dist/publish.js +106 -0
  33. package/dist/rtmp-in.d.ts +22 -0
  34. package/dist/rtmp-in.js +79 -0
  35. package/dist/server.d.ts +94 -0
  36. package/dist/server.js +1158 -12
  37. package/dist/session.d.ts +29 -0
  38. package/dist/session.js +184 -0
  39. package/dist/share.d.ts +26 -0
  40. package/dist/share.js +31 -0
  41. package/package.json +8 -2
  42. package/src/accounts.ts +193 -0
  43. package/src/broadcast.ts +264 -0
  44. package/src/channels.ts +281 -0
  45. package/src/connections.ts +13 -0
  46. package/src/directory.ts +362 -0
  47. package/src/durable.ts +215 -0
  48. package/src/follows.ts +307 -0
  49. package/src/ingest.ts +297 -0
  50. package/src/main.ts +21 -0
  51. package/src/manage.ts +2 -1
  52. package/src/notify.ts +217 -0
  53. package/src/optin.ts +128 -0
  54. package/src/owner.ts +113 -0
  55. package/src/partyline.ts +742 -0
  56. package/src/paywall.ts +198 -0
  57. package/src/playlist.ts +5 -0
  58. package/src/publish.ts +137 -0
  59. package/src/rtmp-in.ts +90 -0
  60. package/src/server.ts +1304 -12
  61. package/src/session.ts +209 -0
  62. package/src/share.ts +40 -0
  63. package/src/types/auth-system.d.ts +77 -0
  64. package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
  65. package/web/dist/assets/index-qRguFskX.js +1 -0
  66. package/web/dist/index.html +62 -6
  67. package/web/dist/install.sh +82 -0
  68. package/web/dist/sw.js +45 -3
  69. 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;
@@ -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
+ }
@@ -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;
@@ -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
+ }