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
package/src/publish.ts ADDED
@@ -0,0 +1,101 @@
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, type Listing } from "./directory.ts";
10
+
11
+ export interface PublishTarget {
12
+ directory: string;
13
+ name: string;
14
+ /** The listen link: what a stranger opens. Never the control key. */
15
+ url: string;
16
+ tracks: number;
17
+ nowPlaying: () => string;
18
+ /**
19
+ * Called with whatever configuration the directory sent back. This is how
20
+ * nixamp.com turns x402 on and off for a server without it restarting.
21
+ */
22
+ onConfig?: (config: unknown) => void;
23
+ }
24
+
25
+ /**
26
+ * Ask, with yes as the default. Returns false without asking when there is no
27
+ * terminal on the other end, which is the case for the daemon and for CI.
28
+ */
29
+ export async function confirm(question: string, tty = process.stdin.isTTY === true): Promise<boolean> {
30
+ if (!tty) return false;
31
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
32
+ try {
33
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
34
+ return answer === "" || answer === "y" || answer === "yes";
35
+ } finally {
36
+ rl.close();
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Announce, then keep announcing. The directory forgets an entry that stops
42
+ * renewing, so stopping the heartbeat is how a stream leaves the list even if
43
+ * the process dies without saying goodbye.
44
+ */
45
+ export class Publisher {
46
+ private id: string | null = null;
47
+ private timer: ReturnType<typeof setInterval> | null = null;
48
+
49
+ constructor(
50
+ private readonly target: PublishTarget,
51
+ private readonly fetcher: typeof fetch = fetch,
52
+ ) {}
53
+
54
+ async start(): Promise<Listing | null> {
55
+ const first = await this.announce();
56
+ this.timer = setInterval(() => void this.announce(), HEARTBEAT_MS);
57
+ this.timer.unref?.();
58
+ return first;
59
+ }
60
+
61
+ async announce(): Promise<Listing | null> {
62
+ try {
63
+ const response = await this.fetcher(`${this.target.directory}/api/directory`, {
64
+ method: "POST",
65
+ headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({
67
+ ...(this.id ? { id: this.id } : {}),
68
+ name: this.target.name,
69
+ url: this.target.url,
70
+ tracks: this.target.tracks,
71
+ nowPlaying: this.target.nowPlaying(),
72
+ }),
73
+ });
74
+ if (!response.ok) return null;
75
+ const listing = (await response.json()) as Listing & { config?: unknown };
76
+ this.id = listing.id;
77
+ if (listing.config !== undefined) this.target.onConfig?.(listing.config);
78
+ return listing;
79
+ } catch {
80
+ // The directory being down is not a reason for a player to stop playing.
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /** Leave the list now rather than waiting to be forgotten. */
86
+ async stop(): Promise<void> {
87
+ if (this.timer) clearInterval(this.timer);
88
+ this.timer = null;
89
+ if (this.id === null) return;
90
+ try {
91
+ await this.fetcher(`${this.target.directory}/api/directory?id=${encodeURIComponent(this.id)}`, {
92
+ method: "DELETE",
93
+ });
94
+ } catch {
95
+ // It expires on its own within the TTL, which is the point of the TTL.
96
+ }
97
+ this.id = null;
98
+ }
99
+ }
100
+
101
+ export { DEFAULT_DIRECTORY };
package/src/rtmp-in.ts ADDED
@@ -0,0 +1,90 @@
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, type ChildProcess } from "node:child_process";
13
+ import type { Channels } from "./channels.ts";
14
+
15
+ export interface RtmpSlot {
16
+ port: number;
17
+ /** The channel a publisher on this port lands on. */
18
+ id: string;
19
+ }
20
+
21
+ /**
22
+ * Arm a listener per slot, and arm it again after each publisher leaves --
23
+ * otherwise a broadcaster who reconnects finds nothing listening.
24
+ */
25
+ export class RtmpListeners {
26
+ private readonly running = new Map<number, ChildProcess>();
27
+ private stopped = false;
28
+
29
+ constructor(
30
+ private readonly channels: Channels,
31
+ private readonly ffmpeg: string[],
32
+ private readonly key: string,
33
+ ) {}
34
+
35
+ listen(slots: RtmpSlot[]): void {
36
+ for (const slot of slots) this.arm(slot);
37
+ }
38
+
39
+ private arm(slot: RtmpSlot): void {
40
+ if (this.stopped || this.running.has(slot.port)) return;
41
+
42
+ const [command, ...prefix] = this.ffmpeg as [string, ...string[]];
43
+ const child = spawn(
44
+ command,
45
+ [
46
+ ...prefix,
47
+ "-hide_banner",
48
+ "-loglevel", "error",
49
+ "-rtmp_listen", "1",
50
+ // Wait indefinitely: a stream that starts tomorrow is still the stream.
51
+ "-timeout", "-1",
52
+ "-f", "flv",
53
+ "-i", `rtmp://0.0.0.0:${slot.port}/live/${this.key}`,
54
+ "-vn",
55
+ "-c:a", "libmp3lame",
56
+ "-b:a", "192k",
57
+ "-y",
58
+ "-f", "mp3",
59
+ "pipe:1",
60
+ ],
61
+ { stdio: ["ignore", "pipe", "pipe"] },
62
+ );
63
+ this.running.set(slot.port, child);
64
+
65
+ // The first bytes are the only honest signal that a publisher turned up:
66
+ // ffmpeg does not announce a connect, and a healthy stream says nothing at
67
+ // -loglevel error.
68
+ let channel: ReturnType<Channels["attach"]> = null;
69
+ child.stdout?.on("data", (chunk: Buffer) => {
70
+ channel ??= this.channels.attach(slot.id, "an RTMP publisher", "flv", "rtmp");
71
+ channel?.feed(chunk);
72
+ });
73
+ child.stdout?.on("error", () => child.kill("SIGKILL"));
74
+ child.on("error", () => this.done(slot, child, channel));
75
+ child.on("close", () => this.done(slot, child, channel));
76
+ }
77
+
78
+ private done(slot: RtmpSlot, child: ChildProcess, channel: { close(): void } | null): void {
79
+ if (this.running.get(slot.port) !== child) return;
80
+ this.running.delete(slot.port);
81
+ channel?.close();
82
+ if (!this.stopped) this.arm(slot);
83
+ }
84
+
85
+ stop(): void {
86
+ this.stopped = true;
87
+ for (const child of this.running.values()) child.kill("SIGKILL");
88
+ this.running.clear();
89
+ }
90
+ }