nixamp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) 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 +63 -0
  11. package/dist/directory.js +111 -0
  12. package/dist/ingest.d.ts +80 -0
  13. package/dist/ingest.js +252 -0
  14. package/dist/main.js +21 -0
  15. package/dist/manage.js +2 -1
  16. package/dist/owner.d.ts +53 -0
  17. package/dist/owner.js +96 -0
  18. package/dist/paywall.d.ts +60 -0
  19. package/dist/paywall.js +162 -0
  20. package/dist/publish.d.ts +36 -0
  21. package/dist/publish.js +90 -0
  22. package/dist/rtmp-in.d.ts +22 -0
  23. package/dist/rtmp-in.js +79 -0
  24. package/dist/server.d.ts +79 -0
  25. package/dist/server.js +609 -10
  26. package/dist/session.d.ts +29 -0
  27. package/dist/session.js +184 -0
  28. package/dist/share.d.ts +16 -0
  29. package/dist/share.js +19 -0
  30. package/package.json +5 -2
  31. package/src/accounts.ts +193 -0
  32. package/src/broadcast.ts +264 -0
  33. package/src/channels.ts +281 -0
  34. package/src/connections.ts +13 -0
  35. package/src/directory.ts +135 -0
  36. package/src/ingest.ts +297 -0
  37. package/src/main.ts +21 -0
  38. package/src/manage.ts +2 -1
  39. package/src/owner.ts +113 -0
  40. package/src/paywall.ts +198 -0
  41. package/src/publish.ts +101 -0
  42. package/src/rtmp-in.ts +90 -0
  43. package/src/server.ts +702 -10
  44. package/src/session.ts +209 -0
  45. package/src/share.ts +27 -0
  46. package/src/types/auth-system.d.ts +77 -0
  47. package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
  48. package/web/dist/assets/index-WYJ6R4uF.js +1 -0
  49. package/web/dist/index.html +37 -6
  50. package/web/dist/sw.js +3 -3
  51. package/web/dist/assets/index-Dhja5wxB.js +0 -1
@@ -0,0 +1,36 @@
1
+ import { DEFAULT_DIRECTORY, type Listing } from "./directory.ts";
2
+ export interface PublishTarget {
3
+ directory: string;
4
+ name: string;
5
+ /** The listen link: what a stranger opens. Never the control key. */
6
+ url: string;
7
+ tracks: number;
8
+ nowPlaying: () => string;
9
+ /**
10
+ * Called with whatever configuration the directory sent back. This is how
11
+ * nixamp.com turns x402 on and off for a server without it restarting.
12
+ */
13
+ onConfig?: (config: unknown) => void;
14
+ }
15
+ /**
16
+ * Ask, with yes as the default. Returns false without asking when there is no
17
+ * terminal on the other end, which is the case for the daemon and for CI.
18
+ */
19
+ export declare function confirm(question: string, tty?: boolean): Promise<boolean>;
20
+ /**
21
+ * Announce, then keep announcing. The directory forgets an entry that stops
22
+ * renewing, so stopping the heartbeat is how a stream leaves the list even if
23
+ * the process dies without saying goodbye.
24
+ */
25
+ export declare class Publisher {
26
+ private readonly target;
27
+ private readonly fetcher;
28
+ private id;
29
+ private timer;
30
+ constructor(target: PublishTarget, fetcher?: typeof fetch);
31
+ start(): Promise<Listing | null>;
32
+ announce(): Promise<Listing | null>;
33
+ /** Leave the list now rather than waiting to be forgotten. */
34
+ stop(): Promise<void>;
35
+ }
36
+ export { DEFAULT_DIRECTORY };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Publishing to the directory, and asking first.
3
+ *
4
+ * Listing a stream tells the world an address it can reach you on, so it is
5
+ * never done silently. The prompt defaults to yes; a terminal that cannot ask
6
+ * defaults to no, because "there was nobody to ask" is not consent.
7
+ */
8
+ import { createInterface } from "node:readline/promises";
9
+ import { DEFAULT_DIRECTORY, HEARTBEAT_MS } from "./directory.js";
10
+ /**
11
+ * Ask, with yes as the default. Returns false without asking when there is no
12
+ * terminal on the other end, which is the case for the daemon and for CI.
13
+ */
14
+ export async function confirm(question, tty = process.stdin.isTTY === true) {
15
+ if (!tty)
16
+ return false;
17
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
18
+ try {
19
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
20
+ return answer === "" || answer === "y" || answer === "yes";
21
+ }
22
+ finally {
23
+ rl.close();
24
+ }
25
+ }
26
+ /**
27
+ * Announce, then keep announcing. The directory forgets an entry that stops
28
+ * renewing, so stopping the heartbeat is how a stream leaves the list even if
29
+ * the process dies without saying goodbye.
30
+ */
31
+ export class Publisher {
32
+ target;
33
+ fetcher;
34
+ id = null;
35
+ timer = null;
36
+ constructor(target, fetcher = fetch) {
37
+ this.target = target;
38
+ this.fetcher = fetcher;
39
+ }
40
+ async start() {
41
+ const first = await this.announce();
42
+ this.timer = setInterval(() => void this.announce(), HEARTBEAT_MS);
43
+ this.timer.unref?.();
44
+ return first;
45
+ }
46
+ async announce() {
47
+ try {
48
+ const response = await this.fetcher(`${this.target.directory}/api/directory`, {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json" },
51
+ body: JSON.stringify({
52
+ ...(this.id ? { id: this.id } : {}),
53
+ name: this.target.name,
54
+ url: this.target.url,
55
+ tracks: this.target.tracks,
56
+ nowPlaying: this.target.nowPlaying(),
57
+ }),
58
+ });
59
+ if (!response.ok)
60
+ return null;
61
+ const listing = (await response.json());
62
+ this.id = listing.id;
63
+ if (listing.config !== undefined)
64
+ this.target.onConfig?.(listing.config);
65
+ return listing;
66
+ }
67
+ catch {
68
+ // The directory being down is not a reason for a player to stop playing.
69
+ return null;
70
+ }
71
+ }
72
+ /** Leave the list now rather than waiting to be forgotten. */
73
+ async stop() {
74
+ if (this.timer)
75
+ clearInterval(this.timer);
76
+ this.timer = null;
77
+ if (this.id === null)
78
+ return;
79
+ try {
80
+ await this.fetcher(`${this.target.directory}/api/directory?id=${encodeURIComponent(this.id)}`, {
81
+ method: "DELETE",
82
+ });
83
+ }
84
+ catch {
85
+ // It expires on its own within the TTL, which is the point of the TTL.
86
+ }
87
+ this.id = null;
88
+ }
89
+ }
90
+ export { DEFAULT_DIRECTORY };
@@ -0,0 +1,22 @@
1
+ import type { Channels } from "./channels.ts";
2
+ export interface RtmpSlot {
3
+ port: number;
4
+ /** The channel a publisher on this port lands on. */
5
+ id: string;
6
+ }
7
+ /**
8
+ * Arm a listener per slot, and arm it again after each publisher leaves --
9
+ * otherwise a broadcaster who reconnects finds nothing listening.
10
+ */
11
+ export declare class RtmpListeners {
12
+ private readonly channels;
13
+ private readonly ffmpeg;
14
+ private readonly key;
15
+ private readonly running;
16
+ private stopped;
17
+ constructor(channels: Channels, ffmpeg: string[], key: string);
18
+ listen(slots: RtmpSlot[]): void;
19
+ private arm;
20
+ private done;
21
+ stop(): void;
22
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * RTMP publishers, several at once.
3
+ *
4
+ * ffmpeg's RTMP listener serves one connection and exits, so N simultaneous
5
+ * publishers means N listeners on N ports. That is the honest cost of using
6
+ * ffmpeg as the RTMP server rather than implementing the protocol, and it buys
7
+ * a broadcaster that every phone and desktop app already speaks.
8
+ *
9
+ * Each listener decodes to MP3 itself, so its channel fans those bytes out
10
+ * without decoding them a second time.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ /**
14
+ * Arm a listener per slot, and arm it again after each publisher leaves --
15
+ * otherwise a broadcaster who reconnects finds nothing listening.
16
+ */
17
+ export class RtmpListeners {
18
+ channels;
19
+ ffmpeg;
20
+ key;
21
+ running = new Map();
22
+ stopped = false;
23
+ constructor(channels, ffmpeg, key) {
24
+ this.channels = channels;
25
+ this.ffmpeg = ffmpeg;
26
+ this.key = key;
27
+ }
28
+ listen(slots) {
29
+ for (const slot of slots)
30
+ this.arm(slot);
31
+ }
32
+ arm(slot) {
33
+ if (this.stopped || this.running.has(slot.port))
34
+ return;
35
+ const [command, ...prefix] = this.ffmpeg;
36
+ const child = spawn(command, [
37
+ ...prefix,
38
+ "-hide_banner",
39
+ "-loglevel", "error",
40
+ "-rtmp_listen", "1",
41
+ // Wait indefinitely: a stream that starts tomorrow is still the stream.
42
+ "-timeout", "-1",
43
+ "-f", "flv",
44
+ "-i", `rtmp://0.0.0.0:${slot.port}/live/${this.key}`,
45
+ "-vn",
46
+ "-c:a", "libmp3lame",
47
+ "-b:a", "192k",
48
+ "-y",
49
+ "-f", "mp3",
50
+ "pipe:1",
51
+ ], { stdio: ["ignore", "pipe", "pipe"] });
52
+ this.running.set(slot.port, child);
53
+ // The first bytes are the only honest signal that a publisher turned up:
54
+ // ffmpeg does not announce a connect, and a healthy stream says nothing at
55
+ // -loglevel error.
56
+ let channel = null;
57
+ child.stdout?.on("data", (chunk) => {
58
+ channel ??= this.channels.attach(slot.id, "an RTMP publisher", "flv", "rtmp");
59
+ channel?.feed(chunk);
60
+ });
61
+ child.stdout?.on("error", () => child.kill("SIGKILL"));
62
+ child.on("error", () => this.done(slot, child, channel));
63
+ child.on("close", () => this.done(slot, child, channel));
64
+ }
65
+ done(slot, child, channel) {
66
+ if (this.running.get(slot.port) !== child)
67
+ return;
68
+ this.running.delete(slot.port);
69
+ channel?.close();
70
+ if (!this.stopped)
71
+ this.arm(slot);
72
+ }
73
+ stop() {
74
+ this.stopped = true;
75
+ for (const child of this.running.values())
76
+ child.kill("SIGKILL");
77
+ this.running.clear();
78
+ }
79
+ }
package/dist/server.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
2
  import { Connections } from "./connections.ts";
3
+ import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast.ts";
4
+ import { Ingest } from "./ingest.ts";
5
+ import { Channels } from "./channels.ts";
6
+ import { Accounts } from "./accounts.ts";
7
+ import { Owner } from "./owner.ts";
8
+ import { Directory } from "./directory.ts";
3
9
  import { type Tools, type Track } from "./audio.ts";
4
10
  import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
5
11
  export declare const SERVE_BAND_COUNT = 24;
@@ -28,6 +34,41 @@ export interface ServeOptions {
28
34
  * failed instead of started.
29
35
  */
30
36
  announce: boolean;
37
+ /** Host the public directory. Only the deployment behind nixamp.com does. */
38
+ directory: boolean;
39
+ /**
40
+ * List this stream at nixamp.com/directory. "ask" prompts, and is the
41
+ * default: publishing an address without being asked is not something a
42
+ * player gets to decide for you.
43
+ */
44
+ publish: "ask" | "yes" | "no";
45
+ /** What to call it in the list. Defaults to this machine's hostname. */
46
+ name: string;
47
+ /**
48
+ * Charge for listening once the stream is busy. Off unless asked for, and
49
+ * useless without somewhere to pay: see NIXAMP_PAY_TO.
50
+ */
51
+ x402: boolean;
52
+ /** The account id that may administer this server, if not the signed-in one. */
53
+ owner: string;
54
+ /** Accept a live stream from a phone or a desktop, over HTTP. */
55
+ ingest: boolean;
56
+ /**
57
+ * Also listen for RTMP publishers on this port, which is what OBS, Larix and
58
+ * anything else native speaks. 0 means do not.
59
+ */
60
+ rtmpIn: number;
61
+ /**
62
+ * How many RTMP publishers may be live at once. ffmpeg's listener serves one
63
+ * connection per process, so this is a port and a process each: 1935, 1936,
64
+ * and so on. HTTP publishers are not limited by this.
65
+ */
66
+ rtmpStreams: number;
67
+ /**
68
+ * RTMP destinations, as `name=rtmp://host/app/key` or `youtube=key` for one
69
+ * of the presets. Repeatable.
70
+ */
71
+ rtmp: string[];
31
72
  }
32
73
  /**
33
74
  * Flags are parsed by hand: three of them do not justify a dependency, and the
@@ -120,6 +161,12 @@ export interface HandlerOptions {
120
161
  version: string;
121
162
  /** The key from the share link, or null to serve to anyone who can connect. */
122
163
  key?: string | null;
164
+ /**
165
+ * A second key that may listen but not drive. The public directory hands
166
+ * this one out: a link that lets a stranger pause your music is not a link
167
+ * you can publish.
168
+ */
169
+ listenKey?: string | null;
123
170
  /** How to run ffmpeg, for the sources a browser cannot play by itself. */
124
171
  ffmpeg?: string[];
125
172
  /** Who is listening, for the admin view. */
@@ -129,6 +176,30 @@ export interface HandlerOptions {
129
176
  * imported so the handler stays a plain function of a request.
130
177
  */
131
178
  load: (source: string) => Promise<Track[]>;
179
+ /**
180
+ * The public directory, on the instance that hosts one. Only nixamp.com
181
+ * passes this; a nixamp on your laptop is a publisher, not a registry.
182
+ */
183
+ directory?: Directory;
184
+ /** Answers a request itself when listening has to be paid for. */
185
+ paywall?: (request: IncomingMessage, response: ServerResponse, path: string) => Promise<boolean>;
186
+ /** Live audio coming in from a phone or a desktop. */
187
+ ingest?: Ingest;
188
+ /** Several live streams at once, each with its own audience. */
189
+ channels?: Channels;
190
+ /** Live audio going out to RTMP. */
191
+ broadcaster?: Broadcaster;
192
+ /** Where a broadcast should send, and what it should look like. */
193
+ broadcast?: () => {
194
+ destinations: Destination[];
195
+ settings: EncoderSettings;
196
+ };
197
+ /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
198
+ accounts?: Accounts;
199
+ /** True when this instance is reached over https, for the cookie's Secure. */
200
+ secureCookies?: boolean;
201
+ /** Who may administer this server. */
202
+ owner?: Owner;
132
203
  }
133
204
  /**
134
205
  * The whole HTTP surface, as a plain function of a request — so a test can
@@ -137,3 +208,11 @@ export interface HandlerOptions {
137
208
  export declare function createHandler(engine: Engine, options: HandlerOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
138
209
  export declare function createServer(engine: Engine, options: HandlerOptions): Server;
139
210
  export declare function serve(argv: string[], version?: string): Promise<void>;
211
+ /**
212
+ * `--rtmp youtube=<key>` or `--rtmp name=rtmp://host/app/key`.
213
+ *
214
+ * A key is a password, so it is taken from the command line or the environment
215
+ * and never from a request: a client that could name its own destination could
216
+ * point your broadcast at itself.
217
+ */
218
+ export declare function parseDestinations(specs: string[]): Destination[];