nixamp 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +225 -0
  2. package/bin/nixamp.mjs +4 -1
  3. package/dist/accounts.d.ts +54 -0
  4. package/dist/accounts.js +160 -0
  5. package/dist/admin.d.ts +47 -0
  6. package/dist/admin.js +209 -0
  7. package/dist/broadcast.d.ts +96 -0
  8. package/dist/broadcast.js +193 -0
  9. package/dist/channels.d.ts +94 -0
  10. package/dist/channels.js +235 -0
  11. package/dist/connections.d.ts +72 -0
  12. package/dist/connections.js +128 -0
  13. package/dist/daemon.d.ts +39 -0
  14. package/dist/daemon.js +170 -0
  15. package/dist/directory.d.ts +63 -0
  16. package/dist/directory.js +111 -0
  17. package/dist/ingest.d.ts +80 -0
  18. package/dist/ingest.js +252 -0
  19. package/dist/main.js +103 -6
  20. package/dist/manage.js +30 -7
  21. package/dist/owner.d.ts +53 -0
  22. package/dist/owner.js +96 -0
  23. package/dist/paywall.d.ts +60 -0
  24. package/dist/paywall.js +162 -0
  25. package/dist/playlist.d.ts +16 -0
  26. package/dist/playlist.js +57 -2
  27. package/dist/publish.d.ts +36 -0
  28. package/dist/publish.js +90 -0
  29. package/dist/rtmp-in.d.ts +22 -0
  30. package/dist/rtmp-in.js +79 -0
  31. package/dist/server.d.ts +117 -4
  32. package/dist/server.js +923 -24
  33. package/dist/session.d.ts +29 -0
  34. package/dist/session.js +184 -0
  35. package/dist/share.d.ts +74 -0
  36. package/dist/share.js +172 -0
  37. package/dist/sources.d.ts +37 -0
  38. package/dist/sources.js +125 -0
  39. package/package.json +5 -2
  40. package/src/accounts.ts +193 -0
  41. package/src/admin.ts +243 -0
  42. package/src/broadcast.ts +264 -0
  43. package/src/channels.ts +281 -0
  44. package/src/connections.ts +158 -0
  45. package/src/daemon.ts +193 -0
  46. package/src/directory.ts +135 -0
  47. package/src/ingest.ts +297 -0
  48. package/src/main.ts +107 -6
  49. package/src/manage.ts +35 -7
  50. package/src/owner.ts +113 -0
  51. package/src/paywall.ts +198 -0
  52. package/src/playlist.ts +68 -2
  53. package/src/publish.ts +101 -0
  54. package/src/rtmp-in.ts +90 -0
  55. package/src/server.ts +1087 -23
  56. package/src/session.ts +209 -0
  57. package/src/share.ts +193 -0
  58. package/src/sources.ts +136 -0
  59. package/src/types/auth-system.d.ts +77 -0
  60. package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
  61. package/web/dist/assets/index-WYJ6R4uF.js +1 -0
  62. package/web/dist/index.html +37 -6
  63. package/web/dist/install.ps1 +214 -0
  64. package/web/dist/sw.js +3 -3
  65. package/web/dist/assets/index-Dhja5wxB.js +0 -1
@@ -0,0 +1,281 @@
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, type ChildProcess } from "node:child_process";
18
+ import { randomBytes } from "node:crypto";
19
+ import type { Readable } from "node:stream";
20
+
21
+ /** Somewhere for a channel's audio to go. A response, in practice. */
22
+ export interface Listener {
23
+ write(chunk: Buffer): boolean;
24
+ end(): void;
25
+ }
26
+
27
+ export interface ChannelInfo {
28
+ id: string;
29
+ /** What the publisher called itself. */
30
+ name: string;
31
+ /** The container it is sending, e.g. webm from a browser, flv over RTMP. */
32
+ format: string;
33
+ /** How it arrived. */
34
+ via: "http" | "rtmp";
35
+ startedAt: number;
36
+ bytes: number;
37
+ listeners: number;
38
+ }
39
+
40
+ /** A name that can sit in a URL and be read back in a list. */
41
+ export function cleanId(value: unknown, fallback = "main"): string {
42
+ if (typeof value !== "string") return fallback;
43
+ const id = value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
44
+ return id.slice(0, 40) || fallback;
45
+ }
46
+
47
+ export interface ChannelOptions {
48
+ ffmpeg: string[];
49
+ onStart?: (info: ChannelInfo) => void;
50
+ onEnd?: (info: ChannelInfo) => void;
51
+ }
52
+
53
+ /**
54
+ * One live source, and its audience.
55
+ *
56
+ * Everything a listener is sent has been through ffmpeg, so a publisher cannot
57
+ * decide what bytes reach a browser by choosing what to send.
58
+ */
59
+ export class Channel {
60
+ readonly listeners = new Set<Listener>();
61
+ private child: ChildProcess | null = null;
62
+ private closing = false;
63
+
64
+ constructor(
65
+ readonly info: ChannelInfo,
66
+ private readonly options: ChannelOptions,
67
+ private readonly onGone: (id: string) => void,
68
+ ) {}
69
+
70
+ start(format: string): void {
71
+ const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
72
+ const child = spawn(
73
+ command,
74
+ [
75
+ ...prefix,
76
+ "-hide_banner",
77
+ "-loglevel", "error",
78
+ // Stated, because ffmpeg mis-probes a live unseekable pipe: it reads a
79
+ // few kilobytes, guesses, and guesses wrong.
80
+ "-f", format,
81
+ "-i", "pipe:0",
82
+ "-vn",
83
+ "-c:a", "libmp3lame",
84
+ "-b:a", "192k",
85
+ "-f", "mp3",
86
+ "pipe:1",
87
+ ],
88
+ { stdio: ["pipe", "pipe", "pipe"] },
89
+ );
90
+
91
+ child.stdout?.on("data", (chunk: Buffer) => {
92
+ this.info.bytes += chunk.byteLength;
93
+ this.send(chunk);
94
+ });
95
+ // A publisher that hangs up mid-write breaks the pipe, and an unhandled
96
+ // EPIPE takes the whole server with it.
97
+ child.stdin?.on("error", () => this.close());
98
+ child.stdout?.on("error", () => this.close());
99
+ child.on("error", () => this.close());
100
+ child.on("close", () => this.close());
101
+
102
+ this.child = child;
103
+ this.options.onStart?.(this.info);
104
+ }
105
+
106
+ /** Feed the source. */
107
+ write(chunk: Buffer): boolean {
108
+ return this.child?.stdin?.write(chunk) ?? false;
109
+ }
110
+
111
+ async pump(body: Readable): Promise<void> {
112
+ for await (const chunk of body) {
113
+ if (this.closing) return;
114
+ if (!this.write(chunk as Buffer)) {
115
+ await new Promise((done) => this.child?.stdin?.once("drain", done) ?? done(null));
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Audio that is already in its final form, from a source we did not spawn.
122
+ * The bytes still only reach a listener after something decoded them; it was
123
+ * simply a different process that did it.
124
+ */
125
+ feed(chunk: Buffer): void {
126
+ this.info.bytes += chunk.byteLength;
127
+ this.send(chunk);
128
+ }
129
+
130
+ /** Write to everyone, and drop anybody whose socket has gone. */
131
+ private send(chunk: Buffer): void {
132
+ for (const listener of this.listeners) {
133
+ try {
134
+ listener.write(chunk);
135
+ } catch {
136
+ // One listener's broken socket is not the channel's problem.
137
+ this.listeners.delete(listener);
138
+ }
139
+ }
140
+ this.info.listeners = this.listeners.size;
141
+ }
142
+
143
+ listen(listener: Listener): () => void {
144
+ this.listeners.add(listener);
145
+ this.info.listeners = this.listeners.size;
146
+ return () => {
147
+ this.listeners.delete(listener);
148
+ this.info.listeners = this.listeners.size;
149
+ };
150
+ }
151
+
152
+ close(): void {
153
+ if (this.closing) return;
154
+ this.closing = true;
155
+ const child = this.child;
156
+ this.child = null;
157
+ try {
158
+ child?.stdin?.end();
159
+ } catch {
160
+ // Already broken, which is usually why we are here.
161
+ }
162
+ child?.kill("SIGKILL");
163
+ // Listeners are ended rather than left hanging on a stream that stopped.
164
+ for (const listener of this.listeners) {
165
+ try {
166
+ listener.end();
167
+ } catch {
168
+ // Gone already.
169
+ }
170
+ }
171
+ this.listeners.clear();
172
+ this.info.listeners = 0;
173
+ this.options.onEnd?.(this.info);
174
+ this.onGone(this.info.id);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Every channel currently live.
180
+ *
181
+ * A channel exists while somebody is publishing to it and disappears when they
182
+ * stop, so the list is what is actually on rather than what was once
183
+ * configured.
184
+ */
185
+ export class Channels {
186
+ private readonly open = new Map<string, Channel>();
187
+
188
+ constructor(private readonly options: ChannelOptions) {}
189
+
190
+ list(): ChannelInfo[] {
191
+ return [...this.open.values()]
192
+ .map((channel) => channel.info)
193
+ .sort((a, b) => a.startedAt - b.startedAt);
194
+ }
195
+
196
+ get count(): number {
197
+ return this.open.size;
198
+ }
199
+
200
+ /** Total listeners across every channel. */
201
+ get listeners(): number {
202
+ let total = 0;
203
+ for (const channel of this.open.values()) total += channel.listeners.size;
204
+ return total;
205
+ }
206
+
207
+ has(id: string): boolean {
208
+ return this.open.has(id);
209
+ }
210
+
211
+ /**
212
+ * Claim a channel and start decoding into it. Null when that channel is
213
+ * already being published to: two publishers on one channel would be two
214
+ * songs at once, which is never what anybody meant. Publishing to a
215
+ * *different* channel is exactly what this class exists for.
216
+ */
217
+ publish(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null {
218
+ if (this.open.has(id)) return null;
219
+ const channel = new Channel(
220
+ {
221
+ id,
222
+ name: name || "a device",
223
+ format,
224
+ via,
225
+ startedAt: Date.now(),
226
+ bytes: 0,
227
+ listeners: 0,
228
+ },
229
+ this.options,
230
+ (gone) => this.open.delete(gone),
231
+ );
232
+ this.open.set(id, channel);
233
+ channel.start(format);
234
+ return channel;
235
+ }
236
+
237
+ /** Attach a listener, or null when nothing is playing on that channel. */
238
+ listen(id: string, listener: Listener): (() => void) | null {
239
+ const channel = this.open.get(id);
240
+ return channel ? channel.listen(listener) : null;
241
+ }
242
+
243
+ /** Feed a channel that already exists, for a publisher sending chunks. */
244
+ writeTo(id: string, chunk: Buffer): boolean {
245
+ return this.open.get(id)?.write(chunk) ?? false;
246
+ }
247
+
248
+ /**
249
+ * A channel fed by audio somebody else is already decoding.
250
+ *
251
+ * An RTMP listener is an ffmpeg with a publisher on one end, and it produces
252
+ * MP3 on its own. Spawning a second ffmpeg to decode what the first one just
253
+ * decoded would double the work to arrive at the same bytes.
254
+ */
255
+ attach(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null {
256
+ if (this.open.has(id)) return null;
257
+ const channel = new Channel(
258
+ { id, name: name || "a device", format, via, startedAt: Date.now(), bytes: 0, listeners: 0 },
259
+ this.options,
260
+ (gone) => this.open.delete(gone),
261
+ );
262
+ this.open.set(id, channel);
263
+ return channel;
264
+ }
265
+
266
+ stop(id: string): boolean {
267
+ const channel = this.open.get(id);
268
+ if (!channel) return false;
269
+ channel.close();
270
+ return true;
271
+ }
272
+
273
+ stopAll(): void {
274
+ for (const channel of [...this.open.values()]) channel.close();
275
+ }
276
+ }
277
+
278
+ /** A channel id nobody chose, for a publisher that did not name one. */
279
+ export function generatedId(): string {
280
+ return `s${randomBytes(3).toString("hex")}`;
281
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Who is listening.
3
+ *
4
+ * A stream is a long-lived request, so the server can say exactly who is
5
+ * connected, to what, since when and how much has gone out. That is the whole
6
+ * point of the admin view: `ss -tn` tells you a socket exists, and nothing
7
+ * about which track is going down it.
8
+ */
9
+ import type { IncomingMessage } from "node:http";
10
+ import { classify } from "./share.ts";
11
+
12
+ export type Kind = "stream" | "media" | "events" | "page";
13
+
14
+ export interface Connection {
15
+ id: number;
16
+ kind: Kind;
17
+ /** The remote address, with an IPv4-mapped IPv6 prefix taken off. */
18
+ address: string;
19
+ /** Where that address lives: your network, tailscale, or the internet. */
20
+ network: "local" | "private" | "cgnat" | "public";
21
+ /** What is going down it, when we know. */
22
+ track: string;
23
+ /** Whatever the client called itself, trimmed to something printable. */
24
+ agent: string;
25
+ startedAt: number;
26
+ /**
27
+ * Bytes handed to the socket, which is not the same as bytes the listener
28
+ * has heard: the kernel buffers, and a slow client can be several seconds
29
+ * behind this number. It is the right figure for "is anything going out".
30
+ */
31
+ bytes: number;
32
+ /** Set when it ends, so the admin view can show what just finished. */
33
+ endedAt: number | null;
34
+ }
35
+
36
+ /** ::ffff:10.0.0.1 is 10.0.0.1 wearing a hat. */
37
+ export function normaliseAddress(address: string | undefined): string {
38
+ if (!address) return "unknown";
39
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
40
+ return mapped?.[1] ?? address;
41
+ }
42
+
43
+ /** Loopback is not "your network"; it is this machine. */
44
+ export function networkOf(address: string): Connection["network"] {
45
+ if (address === "127.0.0.1" || address === "::1" || address === "unknown") return "local";
46
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(address)) return "public";
47
+ return classify(address);
48
+ }
49
+
50
+ /**
51
+ * A user agent, cut to the part that identifies it. Browsers write a paragraph
52
+ * about every engine they have ever pretended to be.
53
+ */
54
+ export function shortAgent(agent: string | undefined): string {
55
+ if (!agent) return "—";
56
+ const known = [
57
+ [/\bFirefox\/([\d.]+)/, "Firefox"],
58
+ [/\bEdg\/([\d.]+)/, "Edge"],
59
+ [/\bOPR\/([\d.]+)/, "Opera"],
60
+ [/\bChrome\/([\d.]+)/, "Chrome"],
61
+ [/\bVersion\/([\d.]+).*\bSafari\//, "Safari"],
62
+ [/\bVLC\/([\d.]+)/, "VLC"],
63
+ [/\bcurl\/([\d.]+)/, "curl"],
64
+ [/\bmpv\b/, "mpv"],
65
+ [/\bLavf\/([\d.]+)/, "ffmpeg"],
66
+ ] as const;
67
+ for (const [pattern, name] of known) {
68
+ const found = pattern.exec(agent);
69
+ if (found) return found[1] ? `${name} ${found[1].split(".")[0]}` : name;
70
+ }
71
+ return agent.slice(0, 24);
72
+ }
73
+
74
+ /**
75
+ * The live set. Finished connections are kept for a while, because "it stopped
76
+ * ten seconds ago" is the most useful thing an admin view can tell you when
77
+ * someone says the stream dropped.
78
+ */
79
+ export class Connections {
80
+ private next = 1;
81
+ private readonly items = new Map<number, Connection>();
82
+ /** How many finished connections to remember. */
83
+ constructor(private readonly keep = 50) {}
84
+
85
+ open(request: IncomingMessage, kind: Kind, track: string): Connection {
86
+ const address = normaliseAddress(request.socket.remoteAddress);
87
+ const connection: Connection = {
88
+ id: this.next++,
89
+ kind,
90
+ address,
91
+ network: networkOf(address),
92
+ track,
93
+ agent: shortAgent(request.headers["user-agent"]),
94
+ startedAt: Date.now(),
95
+ bytes: 0,
96
+ endedAt: null,
97
+ };
98
+ this.items.set(connection.id, connection);
99
+ return connection;
100
+ }
101
+
102
+ close(id: number): void {
103
+ const found = this.items.get(id);
104
+ if (!found || found.endedAt !== null) return;
105
+ found.endedAt = Date.now();
106
+ this.prune();
107
+ }
108
+
109
+ add(id: number, bytes: number): void {
110
+ const found = this.items.get(id);
111
+ if (found) found.bytes += bytes;
112
+ }
113
+
114
+ /**
115
+ * Live first, oldest connection at the top; then the most recently finished.
116
+ *
117
+ * The id breaks ties because Date.now() has millisecond resolution and ten
118
+ * connections can easily end inside one, which left the order down to
119
+ * whatever the sort happened to do.
120
+ */
121
+ list(): Connection[] {
122
+ const all = [...this.items.values()];
123
+ const live = all
124
+ .filter((c) => c.endedAt === null)
125
+ .sort((a, b) => a.startedAt - b.startedAt || a.id - b.id);
126
+ const done = all
127
+ .filter((c) => c.endedAt !== null)
128
+ .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || b.id - a.id);
129
+ return [...live, ...done];
130
+ }
131
+
132
+ /**
133
+ * Live connections that are actually hearing something. The state feed and
134
+ * the page are not listeners, and counting them would put a stream over the
135
+ * free allowance with nobody listening to it.
136
+ */
137
+ get listening(): number {
138
+ let count = 0;
139
+ for (const item of this.items.values()) {
140
+ if (item.endedAt === null && (item.kind === "stream" || item.kind === "media")) count++;
141
+ }
142
+ return count;
143
+ }
144
+
145
+ get active(): number {
146
+ let count = 0;
147
+ for (const item of this.items.values()) if (item.endedAt === null) count++;
148
+ return count;
149
+ }
150
+
151
+ /** Drop the oldest finished entries once there are more than we keep. */
152
+ private prune(): void {
153
+ const done = [...this.items.values()]
154
+ .filter((c) => c.endedAt !== null)
155
+ .sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0) || a.id - b.id);
156
+ for (const item of done.slice(0, Math.max(0, done.length - this.keep))) this.items.delete(item.id);
157
+ }
158
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Daemon mode.
3
+ *
4
+ * `nixamp serve` holds a terminal. A server you leave running should not, and
5
+ * you should be able to walk away and come back to it, which means something on
6
+ * disk has to remember where it is and what key it minted. That file is the
7
+ * whole of the daemon: a pid to signal and enough to reconnect.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+
14
+ export interface DaemonState {
15
+ pid: number;
16
+ host: string;
17
+ port: number;
18
+ /** The share key, so `nixamp admin` can talk to it without being told. */
19
+ key: string | null;
20
+ source: string;
21
+ startedAt: number;
22
+ /** Where its output went, for when it died and you want to know why. */
23
+ log: string;
24
+ }
25
+
26
+ /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
27
+ export function stateDir(): string {
28
+ const base = process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
29
+ return join(base, "nixamp");
30
+ }
31
+
32
+ export function statePath(): string {
33
+ return join(stateDir(), "daemon.json");
34
+ }
35
+
36
+ export function logPath(): string {
37
+ return join(stateDir(), "daemon.log");
38
+ }
39
+
40
+ export function readState(): DaemonState | null {
41
+ try {
42
+ return JSON.parse(readFileSync(statePath(), "utf8")) as DaemonState;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export function writeState(state: DaemonState): void {
49
+ mkdirSync(dirname(statePath()), { recursive: true });
50
+ writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`);
51
+ }
52
+
53
+ export function clearState(): void {
54
+ rmSync(statePath(), { force: true });
55
+ }
56
+
57
+ /**
58
+ * Signal 0 asks "could I signal this?" without sending anything. A pid file
59
+ * outlives the process that wrote it often enough that trusting one is how you
60
+ * end up reporting a daemon that has been dead since Tuesday.
61
+ */
62
+ export function alive(pid: number): boolean {
63
+ try {
64
+ process.kill(pid, 0);
65
+ return true;
66
+ } catch (error) {
67
+ // EPERM means it exists and belongs to someone else, which is still alive.
68
+ return (error as NodeJS.ErrnoException).code === "EPERM";
69
+ }
70
+ }
71
+
72
+ /** The daemon as far as anyone asking is concerned. */
73
+ export function status(): { running: boolean; state: DaemonState | null } {
74
+ const state = readState();
75
+ if (state === null) return { running: false, state: null };
76
+ return { running: alive(state.pid), state };
77
+ }
78
+
79
+ /** The URL an admin client should talk to. */
80
+ export function daemonUrl(state: DaemonState): string {
81
+ const host = state.host === "0.0.0.0" || state.host === "::" ? "127.0.0.1" : state.host;
82
+ return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
83
+ }
84
+
85
+ /**
86
+ * Start one, detached, and wait until it is actually answering before saying
87
+ * it started. Reporting success and leaving the user to discover a crash in a
88
+ * log file is the thing this is meant to avoid.
89
+ */
90
+ export async function start(argv: string[], entry: string): Promise<DaemonState> {
91
+ const existing = status();
92
+ if (existing.running && existing.state) {
93
+ throw new Error(`nixamp: a daemon is already running (pid ${existing.state.pid}). Stop it first.`);
94
+ }
95
+
96
+ mkdirSync(stateDir(), { recursive: true });
97
+ const log = logPath();
98
+ const out = openSync(log, "a");
99
+
100
+ const child = spawn(process.execPath, [entry, "serve", ...argv, "--announce"], {
101
+ detached: true,
102
+ stdio: ["ignore", out, out],
103
+ env: { ...process.env, NIXAMP_DAEMON: "1" },
104
+ });
105
+ child.unref();
106
+ if (child.pid === undefined) throw new Error("nixamp: could not start the daemon");
107
+
108
+ // The server prints one JSON line when it is listening, because guessing how
109
+ // long a start takes is how a flaky `daemon start` is written.
110
+ const announced = await waitForAnnounce(log, 15_000);
111
+ if (announced === null) {
112
+ try {
113
+ process.kill(child.pid, "SIGTERM");
114
+ } catch {
115
+ // Already gone, which is the more likely reason we are here.
116
+ }
117
+ throw new Error(`nixamp: the daemon did not start. See ${log}`);
118
+ }
119
+
120
+ const state: DaemonState = { ...announced, pid: child.pid, startedAt: Date.now(), log };
121
+ writeState(state);
122
+ return state;
123
+ }
124
+
125
+ /** Poll the log for the announce line. */
126
+ async function waitForAnnounce(
127
+ log: string,
128
+ timeoutMs: number,
129
+ ): Promise<Omit<DaemonState, "pid" | "startedAt" | "log"> | null> {
130
+ const deadline = Date.now() + timeoutMs;
131
+ const from = existsSync(log) ? readFileSync(log, "utf8").length : 0;
132
+ while (Date.now() < deadline) {
133
+ await new Promise((done) => setTimeout(done, 100));
134
+ let text: string;
135
+ try {
136
+ text = readFileSync(log, "utf8").slice(from);
137
+ } catch {
138
+ continue;
139
+ }
140
+ for (const line of text.split("\n")) {
141
+ if (!line.startsWith("{")) continue;
142
+ try {
143
+ const parsed = JSON.parse(line) as { nixamp?: string } & Record<string, unknown>;
144
+ if (parsed["nixamp"] === "listening") {
145
+ return {
146
+ host: String(parsed["host"]),
147
+ port: Number(parsed["port"]),
148
+ key: (parsed["key"] as string | null) ?? null,
149
+ source: String(parsed["source"]),
150
+ };
151
+ }
152
+ } catch {
153
+ // A partial line: it will be complete on the next pass.
154
+ }
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /** Stop it, and wait for it to actually be gone. */
161
+ export async function stop(timeoutMs = 5000): Promise<boolean> {
162
+ const { running, state } = status();
163
+ if (!state) return false;
164
+ if (!running) {
165
+ clearState();
166
+ return false;
167
+ }
168
+
169
+ try {
170
+ process.kill(state.pid, "SIGTERM");
171
+ } catch {
172
+ clearState();
173
+ return false;
174
+ }
175
+
176
+ const deadline = Date.now() + timeoutMs;
177
+ while (Date.now() < deadline) {
178
+ if (!alive(state.pid)) {
179
+ clearState();
180
+ return true;
181
+ }
182
+ await new Promise((done) => setTimeout(done, 100));
183
+ }
184
+
185
+ // It ignored SIGTERM. ffmpeg children make that more likely than it sounds.
186
+ try {
187
+ process.kill(state.pid, "SIGKILL");
188
+ } catch {
189
+ // Gone between the check and the signal.
190
+ }
191
+ clearState();
192
+ return true;
193
+ }