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/server.ts CHANGED
@@ -11,12 +11,36 @@
11
11
  */
12
12
  import { createReadStream, statSync } from "node:fs";
13
13
  import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
14
- import { networkInterfaces } from "node:os";
14
+ import { hostname, networkInterfaces } from "node:os";
15
15
  import { spawn, spawnSync } from "node:child_process";
16
16
  import { readFileSync } from "node:fs";
17
17
  import { Connections, type Kind } from "./connections.ts";
18
+ import {
19
+ Broadcaster,
20
+ DEFAULT_ENCODER,
21
+ type Destination,
22
+ type EncoderSettings,
23
+ PRESETS,
24
+ redact,
25
+ } from "./broadcast.ts";
26
+ import { Ingest, normaliseFormat } from "./ingest.ts";
27
+ import { Channels, cleanId } from "./channels.ts";
28
+ import { RtmpListeners } from "./rtmp-in.ts";
29
+ import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
30
+ import { needsAdmin, Owner } from "./owner.ts";
31
+ import { readSession } from "./session.ts";
32
+ import { Directory, parseAnnouncement } from "./directory.ts";
33
+ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
34
+ import {
35
+ applyRemoteConfig,
36
+ createPaywall,
37
+ FREE_LISTENERS,
38
+ type PaywallConfig,
39
+ paywallFromEnv,
40
+ } from "./paywall.ts";
18
41
  import { isRemote } from "./sources.ts";
19
42
  import {
43
+ allowedForListening,
20
44
  elevate,
21
45
  firewallInUse,
22
46
  keyCookie,
@@ -25,9 +49,11 @@ import {
25
49
  newKey,
26
50
  portCommands,
27
51
  reachableAddresses,
52
+ scopeOf,
28
53
  shareLink,
29
54
  } from "./share.ts";
30
55
  import { extname, join, normalize, resolve, sep } from "node:path";
56
+ import { fileURLToPath } from "node:url";
31
57
  import {
32
58
  detectTools, peaks, RATE, Stream, toMono,
33
59
  type Tools, type Track,
@@ -67,6 +93,41 @@ export interface ServeOptions {
67
93
  * failed instead of started.
68
94
  */
69
95
  announce: boolean;
96
+ /** Host the public directory. Only the deployment behind nixamp.com does. */
97
+ directory: boolean;
98
+ /**
99
+ * List this stream at nixamp.com/directory. "ask" prompts, and is the
100
+ * default: publishing an address without being asked is not something a
101
+ * player gets to decide for you.
102
+ */
103
+ publish: "ask" | "yes" | "no";
104
+ /** What to call it in the list. Defaults to this machine's hostname. */
105
+ name: string;
106
+ /**
107
+ * Charge for listening once the stream is busy. Off unless asked for, and
108
+ * useless without somewhere to pay: see NIXAMP_PAY_TO.
109
+ */
110
+ x402: boolean;
111
+ /** The account id that may administer this server, if not the signed-in one. */
112
+ owner: string;
113
+ /** Accept a live stream from a phone or a desktop, over HTTP. */
114
+ ingest: boolean;
115
+ /**
116
+ * Also listen for RTMP publishers on this port, which is what OBS, Larix and
117
+ * anything else native speaks. 0 means do not.
118
+ */
119
+ rtmpIn: number;
120
+ /**
121
+ * How many RTMP publishers may be live at once. ffmpeg's listener serves one
122
+ * connection per process, so this is a port and a process each: 1935, 1936,
123
+ * and so on. HTTP publishers are not limited by this.
124
+ */
125
+ rtmpStreams: number;
126
+ /**
127
+ * RTMP destinations, as `name=rtmp://host/app/key` or `youtube=key` for one
128
+ * of the presets. Repeatable.
129
+ */
130
+ rtmp: string[];
70
131
  }
71
132
 
72
133
  /**
@@ -88,6 +149,15 @@ export function parseServeArgs(argv: string[]): ServeOptions {
88
149
  key: true,
89
150
  openPort: false,
90
151
  announce: false,
152
+ directory: false,
153
+ publish: "ask",
154
+ name: "",
155
+ x402: false,
156
+ owner: "",
157
+ ingest: false,
158
+ rtmpIn: 0,
159
+ rtmpStreams: 3,
160
+ rtmp: [],
91
161
  };
92
162
  let sawRoot = false;
93
163
  for (let i = 0; i < argv.length; i++) {
@@ -116,6 +186,38 @@ export function parseServeArgs(argv: string[]): ServeOptions {
116
186
  options.openPort = true;
117
187
  } else if (arg === "--announce") {
118
188
  options.announce = true;
189
+ } else if (arg === "--directory") {
190
+ options.directory = true;
191
+ } else if (arg === "--publish") {
192
+ options.publish = "yes";
193
+ } else if (arg === "--no-publish") {
194
+ options.publish = "no";
195
+ } else if (arg === "--name") {
196
+ options.name = value();
197
+ } else if (arg === "--owner") {
198
+ options.owner = value();
199
+ } else if (arg === "--ingest") {
200
+ options.ingest = true;
201
+ } else if (arg === "--rtmp-streams") {
202
+ const count = Number(value());
203
+ if (!Number.isInteger(count) || count < 1 || count > 16) {
204
+ throw new Error("nixamp serve: --rtmp-streams must be between 1 and 16");
205
+ }
206
+ options.rtmpStreams = count;
207
+ } else if (arg === "--rtmp-in") {
208
+ const port = Number(value());
209
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
210
+ throw new Error("nixamp serve: --rtmp-in must be a port number");
211
+ }
212
+ options.rtmpIn = port;
213
+ // Listening for RTMP is accepting a live stream, so it implies --ingest.
214
+ options.ingest = true;
215
+ } else if (arg === "--rtmp") {
216
+ options.rtmp.push(value());
217
+ } else if (arg === "--x402") {
218
+ options.x402 = true;
219
+ } else if (arg === "--no-x402") {
220
+ options.x402 = false;
119
221
  } else if (arg.startsWith("-")) {
120
222
  throw new Error(`nixamp serve: unknown option ${arg}`);
121
223
  } else if (!sawRoot) {
@@ -483,6 +585,12 @@ export interface HandlerOptions {
483
585
  version: string;
484
586
  /** The key from the share link, or null to serve to anyone who can connect. */
485
587
  key?: string | null;
588
+ /**
589
+ * A second key that may listen but not drive. The public directory hands
590
+ * this one out: a link that lets a stranger pause your music is not a link
591
+ * you can publish.
592
+ */
593
+ listenKey?: string | null;
486
594
  /** How to run ffmpeg, for the sources a browser cannot play by itself. */
487
595
  ffmpeg?: string[];
488
596
  /** Who is listening, for the admin view. */
@@ -492,6 +600,27 @@ export interface HandlerOptions {
492
600
  * imported so the handler stays a plain function of a request.
493
601
  */
494
602
  load: (source: string) => Promise<Track[]>;
603
+ /**
604
+ * The public directory, on the instance that hosts one. Only nixamp.com
605
+ * passes this; a nixamp on your laptop is a publisher, not a registry.
606
+ */
607
+ directory?: Directory;
608
+ /** Answers a request itself when listening has to be paid for. */
609
+ paywall?: (request: IncomingMessage, response: ServerResponse, path: string) => Promise<boolean>;
610
+ /** Live audio coming in from a phone or a desktop. */
611
+ ingest?: Ingest;
612
+ /** Several live streams at once, each with its own audience. */
613
+ channels?: Channels;
614
+ /** Live audio going out to RTMP. */
615
+ broadcaster?: Broadcaster;
616
+ /** Where a broadcast should send, and what it should look like. */
617
+ broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
618
+ /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
619
+ accounts?: Accounts;
620
+ /** True when this instance is reached over https, for the cookie's Secure. */
621
+ secureCookies?: boolean;
622
+ /** Who may administer this server. */
623
+ owner?: Owner;
495
624
  }
496
625
 
497
626
  /**
@@ -526,6 +655,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
526
655
  const url = new URL(request.url ?? "/", "http://localhost");
527
656
  const path = url.pathname;
528
657
  const key = options.key ?? null;
658
+ const listenKey = options.listenKey ?? null;
529
659
 
530
660
  if (request.method === "OPTIONS") {
531
661
  response.writeHead(204, CORS);
@@ -533,16 +663,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
533
663
  return;
534
664
  }
535
665
 
536
- // Opening the share link is what hands a browser its key. It comes back as
537
- // a cookie, so every later fetch, EventSource and <audio src> carries it
538
- // without the page knowing anything about keys.
666
+ // Opening a share link is what hands a browser its key. It comes back as a
667
+ // cookie, so every later fetch, EventSource and <audio src> carries it
668
+ // without the page knowing anything about keys. Either key works here, and
669
+ // which one was used decides what the browser can then do.
539
670
  if (key !== null && path.startsWith("/s/")) {
540
671
  const offered = decodeURIComponent(path.slice("/s/".length));
541
- if (!keysMatch(offered, key)) {
672
+ if (scopeOf(offered, key, listenKey) === null) {
542
673
  json(response, 404, { error: "not found" });
543
674
  return;
544
675
  }
545
- response.writeHead(302, { ...CORS, "set-cookie": keyCookie(key), location: "/" });
676
+ response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
546
677
  response.end();
547
678
  return;
548
679
  }
@@ -550,12 +681,21 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
550
681
  // /api/health answers unauthenticated on purpose: it is how you check the
551
682
  // port is open from another device before wondering whether the link is
552
683
  // wrong, and it says nothing about the library.
553
- if (key !== null && path !== "/api/health") {
554
- const offered = keyFrom(request, url);
555
- if (offered === null || !keysMatch(offered, key)) {
684
+ if (
685
+ key !== null &&
686
+ path !== "/api/health" &&
687
+ path !== "/api/directory" &&
688
+ !path.startsWith("/api/v1/auth/")
689
+ ) {
690
+ const scope = scopeOf(keyFrom(request, url), key, listenKey);
691
+ if (scope === null) {
556
692
  json(response, 401, { error: "this nixamp needs the key from its share link" });
557
693
  return;
558
694
  }
695
+ if (scope === "listen" && !allowedForListening(path)) {
696
+ json(response, 403, { error: "this link can listen, not drive" });
697
+ return;
698
+ }
559
699
  }
560
700
 
561
701
  if (path === "/api/health") {
@@ -563,6 +703,347 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
563
703
  return;
564
704
  }
565
705
 
706
+ // --- accounts ---------------------------------------------------------
707
+ //
708
+ // Before the share-key check, because signing in is how somebody without a
709
+ // key becomes somebody with one. The API is versioned and namespaced the
710
+ // way the rest of the fleet's is.
711
+ if (path.startsWith("/api/v1/auth/") && options.accounts) {
712
+ const accounts = options.accounts;
713
+ const secure = options.secureCookies ?? false;
714
+
715
+ if (path === "/api/v1/auth/me") {
716
+ const who = await accounts.whoIs(tokenFrom(request.headers));
717
+ if (who === null) {
718
+ json(response, 401, { error: "not signed in" });
719
+ return;
720
+ }
721
+ json(response, 200, { account: who });
722
+ return;
723
+ }
724
+
725
+ if (path === "/api/v1/auth/logout") {
726
+ response.writeHead(200, {
727
+ ...CORS,
728
+ "content-type": "application/json; charset=utf-8",
729
+ "set-cookie": clearedCookie(),
730
+ });
731
+ response.end(JSON.stringify({ ok: true }));
732
+ return;
733
+ }
734
+
735
+ const signingUp = path === "/api/v1/auth/signup";
736
+ if (!signingUp && path !== "/api/v1/auth/login") {
737
+ json(response, 404, { error: "no such endpoint" });
738
+ return;
739
+ }
740
+ if (request.method !== "POST") {
741
+ json(response, 405, { error: "POST only" });
742
+ return;
743
+ }
744
+
745
+ let body: { email?: unknown; password?: unknown };
746
+ try {
747
+ body = JSON.parse(await readBody(request)) as typeof body;
748
+ } catch {
749
+ json(response, 400, { error: "bad JSON" });
750
+ return;
751
+ }
752
+
753
+ const result = signingUp
754
+ ? await accounts.signUp(body.email, body.password)
755
+ : await accounts.signIn(body.email, body.password);
756
+
757
+ if (!result.ok) {
758
+ // 409 for an address that is taken, 401 for credentials that are not.
759
+ json(response, signingUp ? 409 : 401, { error: result.error });
760
+ return;
761
+ }
762
+
763
+ // The token goes back in the body for the CLI and the desktop app, and
764
+ // as a cookie for the browser, which then needs to know nothing about it.
765
+ response.writeHead(200, {
766
+ ...CORS,
767
+ "content-type": "application/json; charset=utf-8",
768
+ "set-cookie": sessionCookie(result.token, secure),
769
+ });
770
+ response.end(JSON.stringify({ account: result.account, token: result.token }));
771
+ return;
772
+ }
773
+
774
+ // The directory is public in both directions: anyone may read the list,
775
+ // and anyone running a nixamp may add themselves to it. It is answered
776
+ // before the key check, because a visitor to nixamp.com has no key and is
777
+ // exactly who it is for.
778
+ if (path === "/api/directory" && options.directory) {
779
+ if (request.method === "GET") {
780
+ json(response, 200, { streams: options.directory.list(), now: Date.now() });
781
+ return;
782
+ }
783
+ if (request.method === "POST") {
784
+ let announcement;
785
+ try {
786
+ announcement = parseAnnouncement(JSON.parse(await readBody(request)));
787
+ } catch {
788
+ json(response, 400, { error: "bad JSON" });
789
+ return;
790
+ }
791
+ if (announcement === null) {
792
+ json(response, 422, { error: "a listing needs a name and a URL a browser can reach" });
793
+ return;
794
+ }
795
+ json(response, 200, options.directory.announce(announcement));
796
+ return;
797
+ }
798
+ if (request.method === "DELETE") {
799
+ const id = url.searchParams.get("id");
800
+ if (id) options.directory.withdraw(id);
801
+ json(response, 200, { ok: true });
802
+ return;
803
+ }
804
+ json(response, 405, { error: "GET, POST or DELETE" });
805
+ return;
806
+ }
807
+
808
+ // Administering is a different question from listening, and it is asked
809
+ // after the share key: the control key answers both, but a listen key or a
810
+ // nixamp.com session answers only one of them.
811
+ if (options.owner && needsAdmin(path, request.method ?? "GET")) {
812
+ // A server started with --no-key has said that anyone who can reach the
813
+ // port may drive it, and prints exactly that. Locking administration to
814
+ // nobody would contradict it and leave such a server unadministrable.
815
+ const holdsControl =
816
+ key === null || scopeOf(keyFrom(request, url), key, null) === "control";
817
+ const check = await options.owner.check(holdsControl, tokenFrom(request.headers));
818
+
819
+ if (path === "/api/admin") {
820
+ // Always answered, and honestly: the page has to know whether to draw
821
+ // an admin panel at all, and "no" is a real answer rather than a 403.
822
+ json(response, 200, { allowed: check.allowed, as: check.as, claimed: options.owner.claimed });
823
+ return;
824
+ }
825
+ if (!check.allowed) {
826
+ json(response, 403, {
827
+ error: options.owner.claimed
828
+ ? "sign in to nixamp.com as this server's owner, or use its control link"
829
+ : "this server has no owner signed in; use its control link",
830
+ });
831
+ return;
832
+ }
833
+ }
834
+
835
+ // After the key check: a paying listener still needs the link, and a 402
836
+ // is a worse answer than a 401 to someone who has neither.
837
+ if (options.paywall && (await options.paywall(request, response, path))) return;
838
+
839
+ // --- several streams at once ------------------------------------------
840
+ //
841
+ // A channel is one publisher and everybody listening to them. Two or three
842
+ // devices can publish at once, each to their own channel, and a listener
843
+ // picks which to hear.
844
+ if (path === "/api/channels" && options.channels) {
845
+ json(response, 200, { channels: options.channels.list(), listeners: options.channels.listeners });
846
+ return;
847
+ }
848
+
849
+ // Publishing. Anyone with the control link may; listening to the result is
850
+ // open to whoever has the share link, like the rest of the audio.
851
+ if (path.startsWith("/api/channels/") && options.channels) {
852
+ const channels = options.channels;
853
+ const rest = path.slice("/api/channels/".length);
854
+ const [rawId, action] = rest.split("/");
855
+ const id = cleanId(rawId);
856
+
857
+ if (action === undefined && request.method === "GET") {
858
+ // Listening. The response is the fan-out target: whatever ffmpeg
859
+ // produces for this channel is written to it until one end goes away.
860
+ const detach = channels.listen(id, response);
861
+ if (detach === null) {
862
+ json(response, 404, { error: "nothing is playing on that channel" });
863
+ return;
864
+ }
865
+ watch(request, response, "stream", id);
866
+ response.writeHead(200, {
867
+ ...CORS,
868
+ "content-type": "audio/mpeg",
869
+ "cache-control": "no-store",
870
+ });
871
+ const leave = (): void => detach();
872
+ request.on("close", leave);
873
+ response.on("close", leave);
874
+ return;
875
+ }
876
+
877
+ if (action === undefined && request.method === "DELETE") {
878
+ // Asked once: the second call would answer false, having just stopped
879
+ // the thing it was asking about.
880
+ const stopped = channels.stop(id);
881
+ json(response, stopped ? 200 : 404, { ok: stopped });
882
+ return;
883
+ }
884
+
885
+ if (request.method !== "POST") {
886
+ json(response, 405, { error: "GET, POST or DELETE" });
887
+ return;
888
+ }
889
+
890
+ const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
891
+ if (format === null) {
892
+ json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
893
+ return;
894
+ }
895
+ const name = url.searchParams.get("name") ?? "";
896
+
897
+ // A chunked publisher sends many requests to one channel, so the first
898
+ // claims it and the rest feed what is already there.
899
+ if (action === "chunk") {
900
+ if (!channels.has(id) && channels.publish(id, name, format, "http") === null) {
901
+ json(response, 409, { error: "that channel is already being published to" });
902
+ return;
903
+ }
904
+ const chunks: Buffer[] = [];
905
+ for await (const chunk of request) chunks.push(chunk as Buffer);
906
+ channels.writeTo(id, Buffer.concat(chunks));
907
+ json(response, 200, { ok: true });
908
+ return;
909
+ }
910
+
911
+ const claimed = channels.publish(id, name, format, "http");
912
+ if (claimed === null) {
913
+ json(response, 409, { error: "that channel is already being published to" });
914
+ return;
915
+ }
916
+ try {
917
+ await claimed.pump(request);
918
+ } catch {
919
+ // A publisher that hung up is not an error worth a 500.
920
+ }
921
+ claimed.close();
922
+ json(response, 200, { ok: true, bytes: claimed.info.bytes });
923
+ return;
924
+ }
925
+
926
+ // --- streaming in ---------------------------------------------------
927
+ //
928
+ // Both shapes write into the same ffmpeg, so everything downstream cannot
929
+ // tell which one a sender used.
930
+ if (path === "/api/ingest" && options.ingest) {
931
+ if (request.method === "GET") {
932
+ json(response, 200, options.ingest.status());
933
+ return;
934
+ }
935
+ if (request.method === "DELETE") {
936
+ options.ingest.close();
937
+ json(response, 200, { ok: true });
938
+ return;
939
+ }
940
+ if (request.method !== "POST") {
941
+ json(response, 405, { error: "GET, POST or DELETE" });
942
+ return;
943
+ }
944
+
945
+ const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
946
+ if (format === null) {
947
+ json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
948
+ return;
949
+ }
950
+
951
+ const session = options.ingest.open(url.searchParams.get("name") ?? "", format);
952
+ if (session === null) {
953
+ // Somebody else is already broadcasting, which is a different problem
954
+ // from the request being wrong.
955
+ json(response, 409, { error: "something is already streaming in" });
956
+ return;
957
+ }
958
+
959
+ try {
960
+ await options.ingest.pump(request);
961
+ } catch {
962
+ // A sender that hung up is not an error worth a 500.
963
+ }
964
+ options.ingest.close();
965
+ json(response, 200, { ok: true, bytes: session.bytes });
966
+ return;
967
+ }
968
+
969
+ // A browser cannot stream a request body over plain HTTP/1.1, so a phone
970
+ // sends its recording a chunk at a time instead.
971
+ if (path === "/api/ingest/chunk" && options.ingest) {
972
+ if (request.method !== "POST") {
973
+ json(response, 405, { error: "POST only" });
974
+ return;
975
+ }
976
+ if (!options.ingest.live) {
977
+ const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
978
+ if (format === null) {
979
+ json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
980
+ return;
981
+ }
982
+ if (options.ingest.open(url.searchParams.get("name") ?? "", format) === null) {
983
+ json(response, 409, { error: "something is already streaming in" });
984
+ return;
985
+ }
986
+ }
987
+ const chunks: Buffer[] = [];
988
+ for await (const chunk of request) chunks.push(chunk as Buffer);
989
+ options.ingest.write(Buffer.concat(chunks));
990
+ json(response, 200, options.ingest.status());
991
+ return;
992
+ }
993
+
994
+ // --- broadcasting out -------------------------------------------------
995
+ if (path === "/api/broadcast" && options.broadcaster) {
996
+ if (request.method === "GET") {
997
+ json(response, 200, options.broadcaster.status());
998
+ return;
999
+ }
1000
+ if (request.method === "DELETE") {
1001
+ options.broadcaster.stop();
1002
+ json(response, 200, options.broadcaster.status());
1003
+ return;
1004
+ }
1005
+ if (request.method !== "POST") {
1006
+ json(response, 405, { error: "GET, POST or DELETE" });
1007
+ return;
1008
+ }
1009
+
1010
+ let source = "";
1011
+ try {
1012
+ source = String((JSON.parse(await readBody(request)) as { source?: unknown }).source ?? "");
1013
+ } catch {
1014
+ json(response, 400, { error: "bad JSON" });
1015
+ return;
1016
+ }
1017
+ const current = engine.snapshot();
1018
+ const chosen = source || engine.trackPath(current.index) || "";
1019
+ if (!chosen) {
1020
+ json(response, 422, { error: "nothing to broadcast" });
1021
+ return;
1022
+ }
1023
+
1024
+ const plan = options.broadcast?.() ?? { destinations: [], settings: DEFAULT_ENCODER };
1025
+ const started = options.broadcaster.start({
1026
+ source: chosen,
1027
+ destinations: plan.destinations,
1028
+ settings: plan.settings,
1029
+ webAudio: false,
1030
+ // Music has no picture, and RTMP platforms insist on a video track.
1031
+ needsVideo: true,
1032
+ });
1033
+ if (!started.ok) {
1034
+ json(response, 422, { error: started.error });
1035
+ return;
1036
+ }
1037
+ json(response, 200, options.broadcaster.status());
1038
+ return;
1039
+ }
1040
+
1041
+ // Names and URLs, never a key.
1042
+ if (path === "/api/broadcast/destinations" && options.broadcast) {
1043
+ json(response, 200, { destinations: options.broadcast().destinations.map(redact) });
1044
+ return;
1045
+ }
1046
+
566
1047
  if (path === "/api/state") {
567
1048
  json(response, 200, engine.snapshot());
568
1049
  return;
@@ -886,13 +1367,85 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
886
1367
 
887
1368
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
888
1369
  const key = options.key ? newKey() : null;
1370
+ // Minted whether or not it is published, so `nixamp admin` and the operator
1371
+ // both have a link they can hand out without handing over the controls.
1372
+ const listenKey = key === null ? null : newKey();
1373
+ // Configuration can arrive from the directory later, so it is a box the
1374
+ // paywall reads rather than a value it was handed once.
1375
+ let paywallConfig: PaywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
1376
+ const connections = new Connections();
1377
+ const paywall = createPaywall({
1378
+ config: () => paywallConfig,
1379
+ liveListeners: () => connections.listening,
1380
+ // The address a payer can actually reach: the public one where there is
1381
+ // one, since a quote pointing at 192.168.1.5 is one they cannot pay from.
1382
+ siteUrl: () => {
1383
+ const bound = server.address();
1384
+ const live = typeof bound === "object" && bound !== null ? bound.port : options.port;
1385
+ const reachable = reachableAddresses(options.host, live);
1386
+ return (reachable.find((a) => a.label === "on the internet") ?? reachable[0])?.url
1387
+ ?? `http://127.0.0.1:${live}`;
1388
+ },
1389
+ // The operator drives with the control key, and is not a customer.
1390
+ exempt: (request) =>
1391
+ key !== null && scopeOf(keyFrom(request, new URL(request.url ?? "/", "http://localhost")), key, null) === "control",
1392
+ });
1393
+
1394
+ const channels = new Channels({
1395
+ ffmpeg: tools.ffmpeg,
1396
+ onStart: (info) =>
1397
+ console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
1398
+ onEnd: (info) => console.log(` "${info.id}" stopped.`),
1399
+ });
1400
+
1401
+ const destinations = parseDestinations(options.rtmp);
1402
+ const broadcaster = new Broadcaster(tools.ffmpeg);
1403
+ const ingest = options.ingest
1404
+ ? new Ingest({
1405
+ ffmpeg: tools.ffmpeg,
1406
+ sink: "pipe:1",
1407
+ onStart: (session) => console.log(` ${session.name} started streaming in (${session.format}).`),
1408
+ onEnd: (session, error) =>
1409
+ console.log(` ${session.name} stopped streaming in${error ? `: ${error}` : ""}.`),
1410
+ })
1411
+ : undefined;
1412
+
1413
+ // The account signed in on this machine owns the server it starts. That is
1414
+ // the whole claim: `nixamp login` then `nixamp serve`, and the phone in your
1415
+ // pocket can administer it from anywhere by signing in as the same person.
1416
+ const session = readSession();
1417
+ const owner = new Owner({
1418
+ ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
1419
+ site: session?.site ?? DEFAULT_DIRECTORY,
1420
+ });
1421
+
889
1422
  const server = createServer(engine, {
890
1423
  web,
891
1424
  media: options.media,
1425
+ owner,
1426
+ channels,
1427
+ ...(ingest ? { ingest } : {}),
1428
+ broadcaster,
1429
+ broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
892
1430
  version,
893
1431
  key,
1432
+ listenKey,
1433
+ connections,
1434
+ paywall,
894
1435
  ffmpeg: tools.ffmpeg,
895
1436
  load: (next) => loadSource(tools, next),
1437
+ ...(options.directory ? { directory: new Directory() } : {}),
1438
+ // Accounts live where the directory lives, and only there: a nixamp on a
1439
+ // laptop has nobody to be an account of.
1440
+ ...(options.directory && process.env["DATABASE_URL"]
1441
+ ? {
1442
+ accounts: new Accounts({
1443
+ connectionString: process.env["DATABASE_URL"],
1444
+ secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
1445
+ }),
1446
+ secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
1447
+ }
1448
+ : {}),
896
1449
  });
897
1450
 
898
1451
  // A port already in use is the most ordinary failure there is, and it
@@ -944,6 +1497,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
944
1497
  } else {
945
1498
  console.log(" Open that link once on a phone or a laptop and it stays signed in.");
946
1499
  console.log(` Anything without the key gets a 401. Key: ${key}`);
1500
+ if (listenKey !== null) {
1501
+ console.log("");
1502
+ console.log(" A listen-only link, for someone you want to hear it but not drive it:");
1503
+ for (const { label, url } of addresses) {
1504
+ if (label === "here") continue;
1505
+ console.log(` ${shareLink(url, listenKey)}`);
1506
+ }
1507
+ }
947
1508
  }
948
1509
  if (addresses.some((a) => a.label === "on the internet")) {
949
1510
  console.log("");
@@ -954,6 +1515,31 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
954
1515
  );
955
1516
  }
956
1517
  if (!options.media) console.log(" Audio stays on this machine: --no-media is set.");
1518
+ if (owner.claimed) {
1519
+ console.log(` ${session?.email} can administer this from anywhere, signed in at ${session?.site}.`);
1520
+ }
1521
+ if (options.ingest) console.log(" Accepting a live stream in at POST /api/ingest.");
1522
+ let rtmp: RtmpListeners | null = null;
1523
+ if (options.rtmpIn > 0) {
1524
+ const publish = addresses.find((a) => a.label !== "here") ?? addresses[0];
1525
+ const host = publish ? new URL(publish.url).hostname : "127.0.0.1";
1526
+ // One listener per stream, because ffmpeg's RTMP listener serves a single
1527
+ // connection per process. Three devices going live at once is three ports.
1528
+ const slots = Array.from({ length: options.rtmpStreams }, (_, i) => ({
1529
+ port: options.rtmpIn + i,
1530
+ id: i === 0 ? "live" : `live-${i + 1}`,
1531
+ }));
1532
+ rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
1533
+ rtmp.listen(slots);
1534
+
1535
+ console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
1536
+ for (const slot of slots) {
1537
+ console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
1538
+ }
1539
+ }
1540
+ if (destinations.length > 0) {
1541
+ console.log(` Ready to broadcast to ${destinations.map((d) => d.name).join(", ")}.`);
1542
+ }
957
1543
  if (web === null) console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
958
1544
 
959
1545
  // Listening on every interface proves the socket is open here and nothing
@@ -994,7 +1580,55 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
994
1580
  }
995
1581
  }
996
1582
 
1583
+ // The listing carries the listen link, and only ever a public address: an
1584
+ // entry pointing at 192.168.1.5 is one nobody outside that house can open.
1585
+ const publishable_ = addresses.find((a) => a.label === "on the internet")
1586
+ ?? addresses.find((a) => a.label === "on tailscale");
1587
+ let publisher: Publisher | null = null;
1588
+
1589
+ if (options.publish !== "no" && publishable_) {
1590
+ const listen = shareLink(publishable_.url, listenKey);
1591
+ const wanted = options.publish === "yes"
1592
+ ? true
1593
+ : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
1594
+
1595
+ if (wanted) {
1596
+ publisher = new Publisher({
1597
+ directory: DEFAULT_DIRECTORY,
1598
+ name: options.name || hostname(),
1599
+ url: listen,
1600
+ tracks: tracks.length,
1601
+ nowPlaying: () => {
1602
+ const snapshot = engine.snapshot();
1603
+ return snapshot.tracks[snapshot.index]?.title ?? "";
1604
+ },
1605
+ onConfig: (remote) => {
1606
+ const next = applyRemoteConfig(paywallConfig, (remote as { x402?: unknown })?.x402);
1607
+ if (JSON.stringify(next) === JSON.stringify(paywallConfig)) return;
1608
+ paywallConfig = next;
1609
+ console.log(next.enabled
1610
+ ? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
1611
+ : " nixamp.com turned paid listening off.");
1612
+ },
1613
+ });
1614
+ const listing = await publisher.start();
1615
+ console.log("");
1616
+ console.log(listing
1617
+ ? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
1618
+ : ` Could not reach ${DEFAULT_DIRECTORY}; not listed.`);
1619
+ }
1620
+ } else if (options.publish === "yes" && !publishable_) {
1621
+ console.log("");
1622
+ console.log(" --publish needs an address the world can reach. This machine has none.");
1623
+ }
1624
+
997
1625
  const shutdown = (): void => {
1626
+ rtmp?.stop();
1627
+ channels.stopAll();
1628
+ ingest?.stopRtmp();
1629
+ ingest?.close();
1630
+ broadcaster.stop();
1631
+ void publisher?.stop();
998
1632
  closePort?.();
999
1633
  engine.stop();
1000
1634
  server.close(() => process.exit(0));
@@ -1005,11 +1639,69 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
1005
1639
  process.on("SIGTERM", shutdown);
1006
1640
  }
1007
1641
 
1642
+ /**
1643
+ * `--rtmp youtube=<key>` or `--rtmp name=rtmp://host/app/key`.
1644
+ *
1645
+ * A key is a password, so it is taken from the command line or the environment
1646
+ * and never from a request: a client that could name its own destination could
1647
+ * point your broadcast at itself.
1648
+ */
1649
+ export function parseDestinations(specs: string[]): Destination[] {
1650
+ const out: Destination[] = [];
1651
+ for (const [index, spec] of specs.entries()) {
1652
+ const at = spec.indexOf("=");
1653
+ if (at <= 0) continue;
1654
+ const name = spec.slice(0, at).trim();
1655
+ const rest = spec.slice(at + 1).trim();
1656
+ if (!name || !rest) continue;
1657
+
1658
+ const preset = PRESETS[name.toLowerCase()];
1659
+ if (preset && !/^rtmps?:\/\//i.test(rest)) {
1660
+ out.push({ id: String(index + 1), name, url: preset, key: rest, enabled: true });
1661
+ continue;
1662
+ }
1663
+ if (!/^rtmps?:\/\//i.test(rest)) continue;
1664
+
1665
+ // A full URL: the last path segment is the key.
1666
+ const cut = rest.lastIndexOf("/");
1667
+ if (cut <= "rtmp://".length) continue;
1668
+ out.push({
1669
+ id: String(index + 1),
1670
+ name,
1671
+ url: rest.slice(0, cut),
1672
+ key: rest.slice(cut + 1),
1673
+ enabled: true,
1674
+ });
1675
+ }
1676
+ return out;
1677
+ }
1678
+
1679
+ /**
1680
+ * Which account the signed-in session belongs to. Asked once at startup rather
1681
+ * than trusted from the file: a token that nixamp.com no longer accepts should
1682
+ * not confer ownership of anything.
1683
+ */
1684
+ async function ownerIdOf(session: { site: string; token: string }): Promise<string> {
1685
+ try {
1686
+ const answer = await fetch(`${session.site}/api/v1/auth/me`, {
1687
+ headers: { authorization: `Bearer ${session.token}` },
1688
+ });
1689
+ if (!answer.ok) return "";
1690
+ const body = (await answer.json()) as { account?: { id?: string } };
1691
+ return typeof body.account?.id === "string" ? body.account.id : "";
1692
+ } catch {
1693
+ // Offline at startup means no remote administration until a restart, and
1694
+ // the control key still works. Better than claiming an owner we cannot
1695
+ // check.
1696
+ return "";
1697
+ }
1698
+ }
1699
+
1008
1700
  /** The built PWA, when it is sitting next to us in the same install. */
1009
1701
  function defaultWebDir(): string | null {
1010
1702
  const fromEnv = process.env.NIXAMP_WEB_DIR;
1011
1703
  if (fromEnv && isFile(join(fromEnv, "index.html"))) return fromEnv;
1012
- const here = new URL(".", import.meta.url).pathname;
1704
+ const here = fileURLToPath(new URL(".", import.meta.url));
1013
1705
  for (const guess of [
1014
1706
  join(here, "..", "web", "dist"),
1015
1707
  join(here, "..", "..", "web", "dist"),