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.
- package/README.md +171 -0
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -0
- package/dist/broadcast.d.ts +96 -0
- package/dist/broadcast.js +193 -0
- package/dist/channels.d.ts +94 -0
- package/dist/channels.js +235 -0
- package/dist/connections.d.ts +6 -0
- package/dist/connections.js +13 -0
- package/dist/directory.d.ts +186 -0
- package/dist/directory.js +275 -0
- package/dist/durable.d.ts +70 -0
- package/dist/durable.js +156 -0
- package/dist/follows.d.ts +92 -0
- package/dist/follows.js +248 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +21 -0
- package/dist/manage.js +2 -1
- package/dist/notify.d.ts +83 -0
- package/dist/notify.js +126 -0
- package/dist/optin.d.ts +37 -0
- package/dist/optin.js +122 -0
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/partyline.d.ts +259 -0
- package/dist/partyline.js +616 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/playlist.js +5 -0
- package/dist/publish.d.ts +57 -0
- package/dist/publish.js +106 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +94 -0
- package/dist/server.js +1158 -12
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +26 -0
- package/dist/share.js +31 -0
- package/package.json +8 -2
- package/src/accounts.ts +193 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +13 -0
- package/src/directory.ts +362 -0
- package/src/durable.ts +215 -0
- package/src/follows.ts +307 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/notify.ts +217 -0
- package/src/optin.ts +128 -0
- package/src/owner.ts +113 -0
- package/src/partyline.ts +742 -0
- package/src/paywall.ts +198 -0
- package/src/playlist.ts +5 -0
- package/src/publish.ts +137 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +1304 -12
- package/src/session.ts +209 -0
- package/src/share.ts +40 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
- package/web/dist/assets/index-qRguFskX.js +1 -0
- package/web/dist/index.html +62 -6
- package/web/dist/install.sh +82 -0
- package/web/dist/sw.js +45 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/src/server.ts
CHANGED
|
@@ -11,12 +11,42 @@
|
|
|
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 { isRemote } from "./sources.ts";
|
|
19
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, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
|
|
33
|
+
import { PartyLine, telnyxSms } from "./partyline.ts";
|
|
34
|
+
import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
|
|
35
|
+
import pg from "pg";
|
|
36
|
+
import { Follows, phoneFrom } from "./follows.ts";
|
|
37
|
+
import { Durable } from "./durable.ts";
|
|
38
|
+
import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
|
|
39
|
+
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
40
|
+
import {
|
|
41
|
+
applyRemoteConfig,
|
|
42
|
+
createPaywall,
|
|
43
|
+
FREE_LISTENERS,
|
|
44
|
+
type PaywallConfig,
|
|
45
|
+
paywallFromEnv,
|
|
46
|
+
} from "./paywall.ts";
|
|
47
|
+
import { isRemote, playsInBrowser } from "./sources.ts";
|
|
48
|
+
import {
|
|
49
|
+
allowedForListening,
|
|
20
50
|
elevate,
|
|
21
51
|
firewallInUse,
|
|
22
52
|
keyCookie,
|
|
@@ -25,9 +55,12 @@ import {
|
|
|
25
55
|
newKey,
|
|
26
56
|
portCommands,
|
|
27
57
|
reachableAddresses,
|
|
58
|
+
scopeOf,
|
|
28
59
|
shareLink,
|
|
60
|
+
audioLink,
|
|
29
61
|
} from "./share.ts";
|
|
30
62
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
63
|
+
import { fileURLToPath } from "node:url";
|
|
31
64
|
import {
|
|
32
65
|
detectTools, peaks, RATE, Stream, toMono,
|
|
33
66
|
type Tools, type Track,
|
|
@@ -67,6 +100,41 @@ export interface ServeOptions {
|
|
|
67
100
|
* failed instead of started.
|
|
68
101
|
*/
|
|
69
102
|
announce: boolean;
|
|
103
|
+
/** Host the public directory. Only the deployment behind nixamp.com does. */
|
|
104
|
+
directory: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* List this stream at nixamp.com/directory. "ask" prompts, and is the
|
|
107
|
+
* default: publishing an address without being asked is not something a
|
|
108
|
+
* player gets to decide for you.
|
|
109
|
+
*/
|
|
110
|
+
publish: "ask" | "yes" | "no";
|
|
111
|
+
/** What to call it in the list. Defaults to this machine's hostname. */
|
|
112
|
+
name: string;
|
|
113
|
+
/**
|
|
114
|
+
* Charge for listening once the stream is busy. Off unless asked for, and
|
|
115
|
+
* useless without somewhere to pay: see NIXAMP_PAY_TO.
|
|
116
|
+
*/
|
|
117
|
+
x402: boolean;
|
|
118
|
+
/** The account id that may administer this server, if not the signed-in one. */
|
|
119
|
+
owner: string;
|
|
120
|
+
/** Accept a live stream from a phone or a desktop, over HTTP. */
|
|
121
|
+
ingest: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Also listen for RTMP publishers on this port, which is what OBS, Larix and
|
|
124
|
+
* anything else native speaks. 0 means do not.
|
|
125
|
+
*/
|
|
126
|
+
rtmpIn: number;
|
|
127
|
+
/**
|
|
128
|
+
* How many RTMP publishers may be live at once. ffmpeg's listener serves one
|
|
129
|
+
* connection per process, so this is a port and a process each: 1935, 1936,
|
|
130
|
+
* and so on. HTTP publishers are not limited by this.
|
|
131
|
+
*/
|
|
132
|
+
rtmpStreams: number;
|
|
133
|
+
/**
|
|
134
|
+
* RTMP destinations, as `name=rtmp://host/app/key` or `youtube=key` for one
|
|
135
|
+
* of the presets. Repeatable.
|
|
136
|
+
*/
|
|
137
|
+
rtmp: string[];
|
|
70
138
|
}
|
|
71
139
|
|
|
72
140
|
/**
|
|
@@ -88,6 +156,15 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
88
156
|
key: true,
|
|
89
157
|
openPort: false,
|
|
90
158
|
announce: false,
|
|
159
|
+
directory: false,
|
|
160
|
+
publish: "ask",
|
|
161
|
+
name: "",
|
|
162
|
+
x402: false,
|
|
163
|
+
owner: "",
|
|
164
|
+
ingest: false,
|
|
165
|
+
rtmpIn: 0,
|
|
166
|
+
rtmpStreams: 3,
|
|
167
|
+
rtmp: [],
|
|
91
168
|
};
|
|
92
169
|
let sawRoot = false;
|
|
93
170
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -116,6 +193,38 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
116
193
|
options.openPort = true;
|
|
117
194
|
} else if (arg === "--announce") {
|
|
118
195
|
options.announce = true;
|
|
196
|
+
} else if (arg === "--directory") {
|
|
197
|
+
options.directory = true;
|
|
198
|
+
} else if (arg === "--publish") {
|
|
199
|
+
options.publish = "yes";
|
|
200
|
+
} else if (arg === "--no-publish") {
|
|
201
|
+
options.publish = "no";
|
|
202
|
+
} else if (arg === "--name") {
|
|
203
|
+
options.name = value();
|
|
204
|
+
} else if (arg === "--owner") {
|
|
205
|
+
options.owner = value();
|
|
206
|
+
} else if (arg === "--ingest") {
|
|
207
|
+
options.ingest = true;
|
|
208
|
+
} else if (arg === "--rtmp-streams") {
|
|
209
|
+
const count = Number(value());
|
|
210
|
+
if (!Number.isInteger(count) || count < 1 || count > 16) {
|
|
211
|
+
throw new Error("nixamp serve: --rtmp-streams must be between 1 and 16");
|
|
212
|
+
}
|
|
213
|
+
options.rtmpStreams = count;
|
|
214
|
+
} else if (arg === "--rtmp-in") {
|
|
215
|
+
const port = Number(value());
|
|
216
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
217
|
+
throw new Error("nixamp serve: --rtmp-in must be a port number");
|
|
218
|
+
}
|
|
219
|
+
options.rtmpIn = port;
|
|
220
|
+
// Listening for RTMP is accepting a live stream, so it implies --ingest.
|
|
221
|
+
options.ingest = true;
|
|
222
|
+
} else if (arg === "--rtmp") {
|
|
223
|
+
options.rtmp.push(value());
|
|
224
|
+
} else if (arg === "--x402") {
|
|
225
|
+
options.x402 = true;
|
|
226
|
+
} else if (arg === "--no-x402") {
|
|
227
|
+
options.x402 = false;
|
|
119
228
|
} else if (arg.startsWith("-")) {
|
|
120
229
|
throw new Error(`nixamp serve: unknown option ${arg}`);
|
|
121
230
|
} else if (!sawRoot) {
|
|
@@ -483,6 +592,12 @@ export interface HandlerOptions {
|
|
|
483
592
|
version: string;
|
|
484
593
|
/** The key from the share link, or null to serve to anyone who can connect. */
|
|
485
594
|
key?: string | null;
|
|
595
|
+
/**
|
|
596
|
+
* A second key that may listen but not drive. The public directory hands
|
|
597
|
+
* this one out: a link that lets a stranger pause your music is not a link
|
|
598
|
+
* you can publish.
|
|
599
|
+
*/
|
|
600
|
+
listenKey?: string | null;
|
|
486
601
|
/** How to run ffmpeg, for the sources a browser cannot play by itself. */
|
|
487
602
|
ffmpeg?: string[];
|
|
488
603
|
/** Who is listening, for the admin view. */
|
|
@@ -492,6 +607,40 @@ export interface HandlerOptions {
|
|
|
492
607
|
* imported so the handler stays a plain function of a request.
|
|
493
608
|
*/
|
|
494
609
|
load: (source: string) => Promise<Track[]>;
|
|
610
|
+
/**
|
|
611
|
+
* The public directory, on the instance that hosts one. Only nixamp.com
|
|
612
|
+
* passes this; a nixamp on your laptop is a publisher, not a registry.
|
|
613
|
+
*/
|
|
614
|
+
directory?: Directory;
|
|
615
|
+
/** Answers a request itself when listening has to be paid for. */
|
|
616
|
+
paywall?: (request: IncomingMessage, response: ServerResponse, path: string) => Promise<boolean>;
|
|
617
|
+
/** Live audio coming in from a phone or a desktop. */
|
|
618
|
+
ingest?: Ingest;
|
|
619
|
+
/** Several live streams at once, each with its own audience. */
|
|
620
|
+
channels?: Channels;
|
|
621
|
+
/** Live audio going out to RTMP. */
|
|
622
|
+
broadcaster?: Broadcaster;
|
|
623
|
+
/** Where a broadcast should send, and what it should look like. */
|
|
624
|
+
broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
|
|
625
|
+
/** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
|
|
626
|
+
accounts?: Accounts;
|
|
627
|
+
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
628
|
+
secureCookies?: boolean;
|
|
629
|
+
/** Who may administer this server. */
|
|
630
|
+
owner?: Owner;
|
|
631
|
+
/**
|
|
632
|
+
* The dial-in party line, on the instance that answers the phone number.
|
|
633
|
+
* Only nixamp.com passes this; a nixamp on a laptop has no number.
|
|
634
|
+
*/
|
|
635
|
+
partyLine?: PartyLine;
|
|
636
|
+
/**
|
|
637
|
+
* Following broadcasters, and where to reach the people who do. Durable,
|
|
638
|
+
* unlike everything else here, because the point of a follow is to outlive
|
|
639
|
+
* the stream.
|
|
640
|
+
*/
|
|
641
|
+
follows?: Follows;
|
|
642
|
+
/** The VAPID public key a browser needs before it can subscribe. */
|
|
643
|
+
vapidPublicKey?: string;
|
|
495
644
|
}
|
|
496
645
|
|
|
497
646
|
/**
|
|
@@ -526,6 +675,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
526
675
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
527
676
|
const path = url.pathname;
|
|
528
677
|
const key = options.key ?? null;
|
|
678
|
+
const listenKey = options.listenKey ?? null;
|
|
529
679
|
|
|
530
680
|
if (request.method === "OPTIONS") {
|
|
531
681
|
response.writeHead(204, CORS);
|
|
@@ -533,29 +683,252 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
533
683
|
return;
|
|
534
684
|
}
|
|
535
685
|
|
|
536
|
-
// Opening
|
|
537
|
-
//
|
|
538
|
-
// without the page knowing anything about keys.
|
|
686
|
+
// Opening a share link is what hands a browser its key. It comes back as a
|
|
687
|
+
// cookie, so every later fetch, EventSource and <audio src> carries it
|
|
688
|
+
// without the page knowing anything about keys. Either key works here, and
|
|
689
|
+
// which one was used decides what the browser can then do.
|
|
539
690
|
if (key !== null && path.startsWith("/s/")) {
|
|
540
691
|
const offered = decodeURIComponent(path.slice("/s/".length));
|
|
541
|
-
if (
|
|
692
|
+
if (scopeOf(offered, key, listenKey) === null) {
|
|
542
693
|
json(response, 404, { error: "not found" });
|
|
543
694
|
return;
|
|
544
695
|
}
|
|
545
|
-
response.writeHead(302, { ...CORS, "set-cookie": keyCookie(
|
|
696
|
+
response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
|
|
546
697
|
response.end();
|
|
547
698
|
return;
|
|
548
699
|
}
|
|
549
700
|
|
|
701
|
+
// The page explaining the reminder texts. Public for the same reason the
|
|
702
|
+
// webhook is: the reader is a carrier reviewing the number, or somebody
|
|
703
|
+
// who just got a message and wants it to stop. Neither has a share link.
|
|
704
|
+
if (path === OPT_IN_PATH && options.partyLine) {
|
|
705
|
+
const body = optInPage();
|
|
706
|
+
response.writeHead(200, {
|
|
707
|
+
...CORS,
|
|
708
|
+
"content-type": "text/html; charset=utf-8",
|
|
709
|
+
"content-length": Buffer.byteLength(body),
|
|
710
|
+
});
|
|
711
|
+
response.end(request.method === "HEAD" ? undefined : body);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// --- following a broadcaster ------------------------------------------
|
|
716
|
+
//
|
|
717
|
+
// Behind the sign-in rather than the share key: a follow belongs to an
|
|
718
|
+
// account, and an account is the only thing that makes "notify me on my
|
|
719
|
+
// other device" mean anything.
|
|
720
|
+
if (path.startsWith("/api/v1/follows") && options.follows && options.accounts) {
|
|
721
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
722
|
+
if (me === null) {
|
|
723
|
+
json(response, 401, { error: "sign in to follow" });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const follows = options.follows;
|
|
727
|
+
|
|
728
|
+
if (path === "/api/v1/follows" && request.method === "GET") {
|
|
729
|
+
const ids = await follows.following(me.id);
|
|
730
|
+
// An id is not a name. The directory is the only thing that knows what
|
|
731
|
+
// an account calls itself, from the last stream it announced -- which
|
|
732
|
+
// is empty for somebody who has never streamed, and the caller decides
|
|
733
|
+
// what to show for that rather than being handed a blank.
|
|
734
|
+
json(response, 200, {
|
|
735
|
+
following: ids.map((id) => ({
|
|
736
|
+
id,
|
|
737
|
+
name: options.directory?.nameOf(id) ?? "",
|
|
738
|
+
live: options.directory?.isLive(id) ?? false,
|
|
739
|
+
})),
|
|
740
|
+
});
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const streamer = path.slice("/api/v1/follows/".length);
|
|
745
|
+
if (!path.startsWith("/api/v1/follows/") || !streamer) {
|
|
746
|
+
json(response, 404, { error: "no such endpoint" });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
751
|
+
const added = await follows.follow(me.id, decodeURIComponent(streamer));
|
|
752
|
+
// Following yourself is refused rather than silently stored: you do
|
|
753
|
+
// not need telling that you went live.
|
|
754
|
+
json(response, added ? 200 : 422, added
|
|
755
|
+
? { following: true, followers: await follows.followerCount(decodeURIComponent(streamer)) }
|
|
756
|
+
: { error: "you cannot follow yourself" });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
if (request.method === "DELETE") {
|
|
760
|
+
await follows.unfollow(me.id, decodeURIComponent(streamer));
|
|
761
|
+
json(response, 200, { following: false });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (request.method === "GET") {
|
|
765
|
+
json(response, 200, { following: await follows.isFollowing(me.id, decodeURIComponent(streamer)) });
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
json(response, 405, { error: "PUT, DELETE or GET" });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// --- where to reach a follower ----------------------------------------
|
|
773
|
+
if (path.startsWith("/api/v1/notify") && options.follows && options.accounts) {
|
|
774
|
+
// The key is public by design: it is what a browser needs before it can
|
|
775
|
+
// ask permission, and it is useless without the private half.
|
|
776
|
+
if (path === "/api/v1/notify/key" && request.method === "GET") {
|
|
777
|
+
json(response, 200, { publicKey: options.vapidPublicKey ?? "" });
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
782
|
+
if (me === null) {
|
|
783
|
+
json(response, 401, { error: "sign in first" });
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const follows = options.follows;
|
|
787
|
+
|
|
788
|
+
if (path === "/api/v1/notify/prefs") {
|
|
789
|
+
if (request.method === "GET") {
|
|
790
|
+
json(response, 200, await follows.prefs(me.id));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (request.method !== "PUT" && request.method !== "POST") {
|
|
794
|
+
json(response, 405, { error: "GET or PUT" });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
let body: Record<string, unknown>;
|
|
798
|
+
try {
|
|
799
|
+
body = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
|
800
|
+
} catch {
|
|
801
|
+
json(response, 400, { error: "bad JSON" });
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
// A number we cannot dial is worse than no number: it is a text that
|
|
805
|
+
// silently goes nowhere for as long as nobody checks.
|
|
806
|
+
if (body["phone"] !== undefined && body["phone"] !== "" && !phoneFrom(body["phone"])) {
|
|
807
|
+
json(response, 422, { error: "that does not look like a phone number" });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
await follows.setPrefs(me.id, {
|
|
811
|
+
...(body["phone"] === undefined ? {} : { phone: String(body["phone"]) }),
|
|
812
|
+
...(typeof body["wantsEmail"] === "boolean" ? { wantsEmail: body["wantsEmail"] } : {}),
|
|
813
|
+
...(typeof body["wantsSms"] === "boolean" ? { wantsSms: body["wantsSms"] } : {}),
|
|
814
|
+
...(typeof body["wantsWeb"] === "boolean" ? { wantsWeb: body["wantsWeb"] } : {}),
|
|
815
|
+
});
|
|
816
|
+
json(response, 200, await follows.prefs(me.id));
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (path === "/api/v1/notify/subscribe") {
|
|
821
|
+
if (request.method === "DELETE") {
|
|
822
|
+
const endpoint = url.searchParams.get("endpoint") ?? "";
|
|
823
|
+
await follows.removePush(endpoint);
|
|
824
|
+
json(response, 200, { ok: true });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (request.method !== "POST" && request.method !== "PUT") {
|
|
828
|
+
json(response, 405, { error: "POST or DELETE" });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
let body: { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown } };
|
|
832
|
+
try {
|
|
833
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
834
|
+
} catch {
|
|
835
|
+
json(response, 400, { error: "bad JSON" });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const endpoint = typeof body.endpoint === "string" ? body.endpoint : "";
|
|
839
|
+
const p256dh = typeof body.keys?.p256dh === "string" ? body.keys.p256dh : "";
|
|
840
|
+
const auth = typeof body.keys?.auth === "string" ? body.keys.auth : "";
|
|
841
|
+
if (!endpoint || !p256dh || !auth) {
|
|
842
|
+
json(response, 422, { error: "a subscription needs an endpoint and both keys" });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
await follows.addPush(me.id, { endpoint, p256dh, auth });
|
|
846
|
+
json(response, 200, { ok: true });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
json(response, 404, { error: "no such endpoint" });
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// --- the party line ---------------------------------------------------
|
|
855
|
+
//
|
|
856
|
+
// Ahead of the share-key check because the caller is a telephone. Telnyx
|
|
857
|
+
// has no cookie and no link; what it has is an ed25519 signature over the
|
|
858
|
+
// body, which is a stronger claim than a key in a URL anyway.
|
|
859
|
+
if (path.startsWith("/api/v1/partyline/") && options.partyLine) {
|
|
860
|
+
const partyLine = options.partyLine;
|
|
861
|
+
|
|
862
|
+
if (path === "/api/v1/partyline/rooms") {
|
|
863
|
+
json(response, 200, { rooms: partyLine.list() });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (path !== "/api/v1/partyline/webhook") {
|
|
868
|
+
json(response, 404, { error: "no such endpoint" });
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (request.method !== "POST") {
|
|
872
|
+
json(response, 405, { error: "POST only" });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// The bytes as they arrived. Parsing first and reserialising would
|
|
877
|
+
// change the whitespace the signature was computed over.
|
|
878
|
+
let raw: string;
|
|
879
|
+
try {
|
|
880
|
+
raw = await readBody(request);
|
|
881
|
+
} catch {
|
|
882
|
+
json(response, 413, { error: "body too large" });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const signature = request.headers["telnyx-signature-ed25519"];
|
|
887
|
+
const timestamp = request.headers["telnyx-timestamp"];
|
|
888
|
+
const ok = partyLine.verify(
|
|
889
|
+
raw,
|
|
890
|
+
typeof signature === "string" ? signature : undefined,
|
|
891
|
+
typeof timestamp === "string" ? timestamp : undefined,
|
|
892
|
+
);
|
|
893
|
+
if (!ok) {
|
|
894
|
+
json(response, 401, { error: "bad signature" });
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
let event: { data?: { event_type?: string; payload?: Record<string, unknown> } };
|
|
899
|
+
try {
|
|
900
|
+
event = JSON.parse(raw) as typeof event;
|
|
901
|
+
} catch {
|
|
902
|
+
json(response, 400, { error: "bad JSON" });
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Answer first, act second. Telnyx retries anything it does not hear
|
|
907
|
+
// back about quickly, and a retried call.answered would ask the caller
|
|
908
|
+
// which room they wanted twice.
|
|
909
|
+
json(response, 200, { ok: true });
|
|
910
|
+
void partyLine.handle(event.data ?? {}).catch(() => {});
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
550
914
|
// /api/health answers unauthenticated on purpose: it is how you check the
|
|
551
915
|
// port is open from another device before wondering whether the link is
|
|
552
916
|
// wrong, and it says nothing about the library.
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
917
|
+
if (
|
|
918
|
+
key !== null &&
|
|
919
|
+
path !== "/api/health" &&
|
|
920
|
+
path !== "/api/directory" &&
|
|
921
|
+
!path.startsWith("/api/v1/auth/")
|
|
922
|
+
) {
|
|
923
|
+
const scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
924
|
+
if (scope === null) {
|
|
556
925
|
json(response, 401, { error: "this nixamp needs the key from its share link" });
|
|
557
926
|
return;
|
|
558
927
|
}
|
|
928
|
+
if (scope === "listen" && !allowedForListening(path)) {
|
|
929
|
+
json(response, 403, { error: "this link can listen, not drive" });
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
559
932
|
}
|
|
560
933
|
|
|
561
934
|
if (path === "/api/health") {
|
|
@@ -563,6 +936,394 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
563
936
|
return;
|
|
564
937
|
}
|
|
565
938
|
|
|
939
|
+
// --- accounts ---------------------------------------------------------
|
|
940
|
+
//
|
|
941
|
+
// Before the share-key check, because signing in is how somebody without a
|
|
942
|
+
// key becomes somebody with one. The API is versioned and namespaced the
|
|
943
|
+
// way the rest of the fleet's is.
|
|
944
|
+
if (path.startsWith("/api/v1/auth/") && options.accounts) {
|
|
945
|
+
const accounts = options.accounts;
|
|
946
|
+
const secure = options.secureCookies ?? false;
|
|
947
|
+
|
|
948
|
+
if (path === "/api/v1/auth/me") {
|
|
949
|
+
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
950
|
+
if (who === null) {
|
|
951
|
+
json(response, 401, { error: "not signed in" });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
json(response, 200, { account: who });
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (path === "/api/v1/auth/logout") {
|
|
959
|
+
response.writeHead(200, {
|
|
960
|
+
...CORS,
|
|
961
|
+
"content-type": "application/json; charset=utf-8",
|
|
962
|
+
"set-cookie": clearedCookie(),
|
|
963
|
+
});
|
|
964
|
+
response.end(JSON.stringify({ ok: true }));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const signingUp = path === "/api/v1/auth/signup";
|
|
969
|
+
if (!signingUp && path !== "/api/v1/auth/login") {
|
|
970
|
+
json(response, 404, { error: "no such endpoint" });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (request.method !== "POST") {
|
|
974
|
+
json(response, 405, { error: "POST only" });
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
let body: { email?: unknown; password?: unknown };
|
|
979
|
+
try {
|
|
980
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
981
|
+
} catch {
|
|
982
|
+
json(response, 400, { error: "bad JSON" });
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const result = signingUp
|
|
987
|
+
? await accounts.signUp(body.email, body.password)
|
|
988
|
+
: await accounts.signIn(body.email, body.password);
|
|
989
|
+
|
|
990
|
+
if (!result.ok) {
|
|
991
|
+
// 409 for an address that is taken, 401 for credentials that are not.
|
|
992
|
+
json(response, signingUp ? 409 : 401, { error: result.error });
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// The token goes back in the body for the CLI and the desktop app, and
|
|
997
|
+
// as a cookie for the browser, which then needs to know nothing about it.
|
|
998
|
+
response.writeHead(200, {
|
|
999
|
+
...CORS,
|
|
1000
|
+
"content-type": "application/json; charset=utf-8",
|
|
1001
|
+
"set-cookie": sessionCookie(result.token, secure),
|
|
1002
|
+
});
|
|
1003
|
+
response.end(JSON.stringify({ account: result.account, token: result.token }));
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// The directory is public to read and answered before the key check,
|
|
1008
|
+
// because a visitor to nixamp.com has no key and is exactly who it is for.
|
|
1009
|
+
//
|
|
1010
|
+
// Announcing is not public any more. A listing now carries a phone number
|
|
1011
|
+
// people dial and minutes we pay for, so it has to be attributable to
|
|
1012
|
+
// somebody: broadcasters register, and a caller just dials. Reading stays
|
|
1013
|
+
// open to everyone -- the whole point is a directory a stranger can browse.
|
|
1014
|
+
if (path === "/api/directory" && options.directory) {
|
|
1015
|
+
if (request.method === "GET") {
|
|
1016
|
+
// Each stream carries its call-in code and how many people are on the
|
|
1017
|
+
// phone for it. The code is published on purpose: it is a public
|
|
1018
|
+
// call-in line, and a listing you cannot dial is a listing of nothing.
|
|
1019
|
+
const onThePhone = options.partyLine;
|
|
1020
|
+
const streams = options.directory.list().map((stream) => ({
|
|
1021
|
+
...stream,
|
|
1022
|
+
callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
|
|
1023
|
+
}));
|
|
1024
|
+
// Recently ended too, because following exists to hear about
|
|
1025
|
+
// broadcasts you would otherwise miss -- and a list of only what is on
|
|
1026
|
+
// can only be used to follow somebody during a broadcast you did not
|
|
1027
|
+
// miss. No url and no code: there is nothing to listen to.
|
|
1028
|
+
const recent = options.directory.recentlyEnded().map((stream) => ({
|
|
1029
|
+
name: stream.name,
|
|
1030
|
+
ownerId: stream.ownerId,
|
|
1031
|
+
nowPlaying: stream.nowPlaying,
|
|
1032
|
+
endedAt: stream.endedAt,
|
|
1033
|
+
}));
|
|
1034
|
+
json(response, 200, { streams, recent, callIn: CALL_IN_NUMBER, now: Date.now() });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (request.method === "POST") {
|
|
1038
|
+
// Only where there are accounts to check against. An instance with no
|
|
1039
|
+
// Accounts is somebody's laptop, which has no registration to demand.
|
|
1040
|
+
let ownerId = "";
|
|
1041
|
+
if (options.accounts) {
|
|
1042
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1043
|
+
if (who === null) {
|
|
1044
|
+
json(response, 401, {
|
|
1045
|
+
error: "sign in to list a stream: nixamp login, then nixamp serve --directory",
|
|
1046
|
+
});
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
// From the token, never the body. A stream that could name its own
|
|
1050
|
+
// owner could name somebody else's, and their followers would be
|
|
1051
|
+
// told about a broadcast that person is not making.
|
|
1052
|
+
ownerId = who.id;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
let announcement;
|
|
1056
|
+
try {
|
|
1057
|
+
announcement = parseAnnouncement(JSON.parse(await readBody(request)));
|
|
1058
|
+
} catch {
|
|
1059
|
+
json(response, 400, { error: "bad JSON" });
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (announcement === null) {
|
|
1063
|
+
json(response, 422, { error: "a listing needs a name and a URL a browser can reach" });
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const listing = options.directory.announce(announcement, ownerId);
|
|
1067
|
+
// A stream reappearing is the event somebody asked to be told about.
|
|
1068
|
+
// Answered first and texted after, because the publisher's heartbeat
|
|
1069
|
+
// should not wait on an SMS gateway.
|
|
1070
|
+
json(response, 200, listing);
|
|
1071
|
+
if (options.partyLine) {
|
|
1072
|
+
void options.partyLine
|
|
1073
|
+
.wentLive({ code: listing.code, name: listing.name, nowPlaying: listing.nowPlaying })
|
|
1074
|
+
.catch(() => {});
|
|
1075
|
+
}
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (request.method === "DELETE") {
|
|
1079
|
+
const id = url.searchParams.get("id");
|
|
1080
|
+
if (id) options.directory.withdraw(id);
|
|
1081
|
+
json(response, 200, { ok: true });
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Administering is a different question from listening, and it is asked
|
|
1089
|
+
// after the share key: the control key answers both, but a listen key or a
|
|
1090
|
+
// nixamp.com session answers only one of them.
|
|
1091
|
+
if (options.owner && needsAdmin(path, request.method ?? "GET")) {
|
|
1092
|
+
// A server started with --no-key has said that anyone who can reach the
|
|
1093
|
+
// port may drive it, and prints exactly that. Locking administration to
|
|
1094
|
+
// nobody would contradict it and leave such a server unadministrable.
|
|
1095
|
+
const holdsControl =
|
|
1096
|
+
key === null || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
1097
|
+
const check = await options.owner.check(holdsControl, tokenFrom(request.headers));
|
|
1098
|
+
|
|
1099
|
+
if (path === "/api/admin") {
|
|
1100
|
+
// Always answered, and honestly: the page has to know whether to draw
|
|
1101
|
+
// an admin panel at all, and "no" is a real answer rather than a 403.
|
|
1102
|
+
json(response, 200, { allowed: check.allowed, as: check.as, claimed: options.owner.claimed });
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (!check.allowed) {
|
|
1106
|
+
json(response, 403, {
|
|
1107
|
+
error: options.owner.claimed
|
|
1108
|
+
? "sign in to nixamp.com as this server's owner, or use its control link"
|
|
1109
|
+
: "this server has no owner signed in; use its control link",
|
|
1110
|
+
});
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// After the key check: a paying listener still needs the link, and a 402
|
|
1116
|
+
// is a worse answer than a 401 to someone who has neither.
|
|
1117
|
+
if (options.paywall && (await options.paywall(request, response, path))) return;
|
|
1118
|
+
|
|
1119
|
+
// --- several streams at once ------------------------------------------
|
|
1120
|
+
//
|
|
1121
|
+
// A channel is one publisher and everybody listening to them. Two or three
|
|
1122
|
+
// devices can publish at once, each to their own channel, and a listener
|
|
1123
|
+
// picks which to hear.
|
|
1124
|
+
if (path === "/api/channels" && options.channels) {
|
|
1125
|
+
json(response, 200, { channels: options.channels.list(), listeners: options.channels.listeners });
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Publishing. Anyone with the control link may; listening to the result is
|
|
1130
|
+
// open to whoever has the share link, like the rest of the audio.
|
|
1131
|
+
if (path.startsWith("/api/channels/") && options.channels) {
|
|
1132
|
+
const channels = options.channels;
|
|
1133
|
+
const rest = path.slice("/api/channels/".length);
|
|
1134
|
+
const [rawId, action] = rest.split("/");
|
|
1135
|
+
const id = cleanId(rawId);
|
|
1136
|
+
|
|
1137
|
+
if (action === undefined && request.method === "GET") {
|
|
1138
|
+
// Listening. The response is the fan-out target: whatever ffmpeg
|
|
1139
|
+
// produces for this channel is written to it until one end goes away.
|
|
1140
|
+
const detach = channels.listen(id, response);
|
|
1141
|
+
if (detach === null) {
|
|
1142
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
watch(request, response, "stream", id);
|
|
1146
|
+
response.writeHead(200, {
|
|
1147
|
+
...CORS,
|
|
1148
|
+
"content-type": "audio/mpeg",
|
|
1149
|
+
"cache-control": "no-store",
|
|
1150
|
+
});
|
|
1151
|
+
const leave = (): void => detach();
|
|
1152
|
+
request.on("close", leave);
|
|
1153
|
+
response.on("close", leave);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (action === undefined && request.method === "DELETE") {
|
|
1158
|
+
// Asked once: the second call would answer false, having just stopped
|
|
1159
|
+
// the thing it was asking about.
|
|
1160
|
+
const stopped = channels.stop(id);
|
|
1161
|
+
json(response, stopped ? 200 : 404, { ok: stopped });
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (request.method !== "POST") {
|
|
1166
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
1171
|
+
if (format === null) {
|
|
1172
|
+
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const name = url.searchParams.get("name") ?? "";
|
|
1176
|
+
|
|
1177
|
+
// A chunked publisher sends many requests to one channel, so the first
|
|
1178
|
+
// claims it and the rest feed what is already there.
|
|
1179
|
+
if (action === "chunk") {
|
|
1180
|
+
if (!channels.has(id) && channels.publish(id, name, format, "http") === null) {
|
|
1181
|
+
json(response, 409, { error: "that channel is already being published to" });
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const chunks: Buffer[] = [];
|
|
1185
|
+
for await (const chunk of request) chunks.push(chunk as Buffer);
|
|
1186
|
+
channels.writeTo(id, Buffer.concat(chunks));
|
|
1187
|
+
json(response, 200, { ok: true });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const claimed = channels.publish(id, name, format, "http");
|
|
1192
|
+
if (claimed === null) {
|
|
1193
|
+
json(response, 409, { error: "that channel is already being published to" });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
await claimed.pump(request);
|
|
1198
|
+
} catch {
|
|
1199
|
+
// A publisher that hung up is not an error worth a 500.
|
|
1200
|
+
}
|
|
1201
|
+
claimed.close();
|
|
1202
|
+
json(response, 200, { ok: true, bytes: claimed.info.bytes });
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// --- streaming in ---------------------------------------------------
|
|
1207
|
+
//
|
|
1208
|
+
// Both shapes write into the same ffmpeg, so everything downstream cannot
|
|
1209
|
+
// tell which one a sender used.
|
|
1210
|
+
if (path === "/api/ingest" && options.ingest) {
|
|
1211
|
+
if (request.method === "GET") {
|
|
1212
|
+
json(response, 200, options.ingest.status());
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (request.method === "DELETE") {
|
|
1216
|
+
options.ingest.close();
|
|
1217
|
+
json(response, 200, { ok: true });
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
if (request.method !== "POST") {
|
|
1221
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
1226
|
+
if (format === null) {
|
|
1227
|
+
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const session = options.ingest.open(url.searchParams.get("name") ?? "", format);
|
|
1232
|
+
if (session === null) {
|
|
1233
|
+
// Somebody else is already broadcasting, which is a different problem
|
|
1234
|
+
// from the request being wrong.
|
|
1235
|
+
json(response, 409, { error: "something is already streaming in" });
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
try {
|
|
1240
|
+
await options.ingest.pump(request);
|
|
1241
|
+
} catch {
|
|
1242
|
+
// A sender that hung up is not an error worth a 500.
|
|
1243
|
+
}
|
|
1244
|
+
options.ingest.close();
|
|
1245
|
+
json(response, 200, { ok: true, bytes: session.bytes });
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// A browser cannot stream a request body over plain HTTP/1.1, so a phone
|
|
1250
|
+
// sends its recording a chunk at a time instead.
|
|
1251
|
+
if (path === "/api/ingest/chunk" && options.ingest) {
|
|
1252
|
+
if (request.method !== "POST") {
|
|
1253
|
+
json(response, 405, { error: "POST only" });
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (!options.ingest.live) {
|
|
1257
|
+
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
1258
|
+
if (format === null) {
|
|
1259
|
+
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (options.ingest.open(url.searchParams.get("name") ?? "", format) === null) {
|
|
1263
|
+
json(response, 409, { error: "something is already streaming in" });
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const chunks: Buffer[] = [];
|
|
1268
|
+
for await (const chunk of request) chunks.push(chunk as Buffer);
|
|
1269
|
+
options.ingest.write(Buffer.concat(chunks));
|
|
1270
|
+
json(response, 200, options.ingest.status());
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// --- broadcasting out -------------------------------------------------
|
|
1275
|
+
if (path === "/api/broadcast" && options.broadcaster) {
|
|
1276
|
+
if (request.method === "GET") {
|
|
1277
|
+
json(response, 200, options.broadcaster.status());
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (request.method === "DELETE") {
|
|
1281
|
+
options.broadcaster.stop();
|
|
1282
|
+
json(response, 200, options.broadcaster.status());
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (request.method !== "POST") {
|
|
1286
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
let source = "";
|
|
1291
|
+
try {
|
|
1292
|
+
source = String((JSON.parse(await readBody(request)) as { source?: unknown }).source ?? "");
|
|
1293
|
+
} catch {
|
|
1294
|
+
json(response, 400, { error: "bad JSON" });
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
const current = engine.snapshot();
|
|
1298
|
+
const chosen = source || engine.trackPath(current.index) || "";
|
|
1299
|
+
if (!chosen) {
|
|
1300
|
+
json(response, 422, { error: "nothing to broadcast" });
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const plan = options.broadcast?.() ?? { destinations: [], settings: DEFAULT_ENCODER };
|
|
1305
|
+
const started = options.broadcaster.start({
|
|
1306
|
+
source: chosen,
|
|
1307
|
+
destinations: plan.destinations,
|
|
1308
|
+
settings: plan.settings,
|
|
1309
|
+
webAudio: false,
|
|
1310
|
+
// Music has no picture, and RTMP platforms insist on a video track.
|
|
1311
|
+
needsVideo: true,
|
|
1312
|
+
});
|
|
1313
|
+
if (!started.ok) {
|
|
1314
|
+
json(response, 422, { error: started.error });
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
json(response, 200, options.broadcaster.status());
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Names and URLs, never a key.
|
|
1322
|
+
if (path === "/api/broadcast/destinations" && options.broadcast) {
|
|
1323
|
+
json(response, 200, { destinations: options.broadcast().destinations.map(redact) });
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
566
1327
|
if (path === "/api/state") {
|
|
567
1328
|
json(response, 200, engine.snapshot());
|
|
568
1329
|
return;
|
|
@@ -672,7 +1433,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
672
1433
|
return;
|
|
673
1434
|
}
|
|
674
1435
|
watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
|
|
675
|
-
|
|
1436
|
+
// A browser asks for every track here, and a matroska or an avi handed
|
|
1437
|
+
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
1438
|
+
// and transcoding gives it up, but an unseekable film beats a silent
|
|
1439
|
+
// one -- and the seekable formats are untouched.
|
|
1440
|
+
if (playsInBrowser(file)) sendFile(request, response, file);
|
|
1441
|
+
else transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
676
1442
|
return;
|
|
677
1443
|
}
|
|
678
1444
|
|
|
@@ -680,6 +1446,23 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
680
1446
|
// a flac, a wma, a URL, an HLS stream. ffmpeg reads them all and we hand
|
|
681
1447
|
// the bytes on as they arrive, so a live stream starts immediately rather
|
|
682
1448
|
// than after it ends, which for a live stream is never.
|
|
1449
|
+
// One address that keeps playing, for a listener that cannot ask for the
|
|
1450
|
+
// next track: the phone line hands exactly this to Telnyx.
|
|
1451
|
+
if (path === "/api/live") {
|
|
1452
|
+
if (!options.media) {
|
|
1453
|
+
json(response, 403, { error: "media streaming is off" });
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const current = engine.snapshot();
|
|
1457
|
+
if (current.tracks.length === 0) {
|
|
1458
|
+
json(response, 404, { error: "nothing is playing" });
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
watch(request, response, "stream", current.tracks[current.index]?.title ?? "live");
|
|
1462
|
+
liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
683
1466
|
if (path.startsWith("/api/stream/")) {
|
|
684
1467
|
const index = Number(path.slice("/api/stream/".length));
|
|
685
1468
|
const source = Number.isInteger(index) ? engine.trackPath(index) : undefined;
|
|
@@ -731,6 +1514,138 @@ function readIfPossible(path: string): string | null {
|
|
|
731
1514
|
}
|
|
732
1515
|
}
|
|
733
1516
|
|
|
1517
|
+
/** How long to wait before looking again when the player has not moved on. */
|
|
1518
|
+
const LIVE_GAP_MS = 500;
|
|
1519
|
+
/** How long to wait when there is nothing to play at all yet. */
|
|
1520
|
+
const LIVE_IDLE_MS = 2000;
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Whatever is playing, as one endless MP3.
|
|
1524
|
+
*
|
|
1525
|
+
* /api/stream/N is one track: it needs an index, and it stops at the end of
|
|
1526
|
+
* the song. That is right for a browser, which knows what is playing and can
|
|
1527
|
+
* ask for the next one. It is wrong for everything that cannot -- a telephone
|
|
1528
|
+
* call, `curl | mpv`, anything handed a single address and expected to keep
|
|
1529
|
+
* hearing sound. Those need one URL that never ends and never needs asking
|
|
1530
|
+
* again, which is what a listener means by "the stream".
|
|
1531
|
+
*
|
|
1532
|
+
* So this follows the player rather than an index: transcode what is playing,
|
|
1533
|
+
* and when that track ends look at what is playing now and keep writing into
|
|
1534
|
+
* the same response. The listener sees one continuous audio/mpeg body.
|
|
1535
|
+
*
|
|
1536
|
+
* Read at native rate (-re), unlike /api/stream/N which is free to run ahead
|
|
1537
|
+
* into a browser's buffer. Here running ahead would finish the song in two
|
|
1538
|
+
* seconds and then sit waiting for the player to catch up, so the thing that
|
|
1539
|
+
* decides what plays next would be minutes behind what the listener hears.
|
|
1540
|
+
*/
|
|
1541
|
+
function liveAudio(
|
|
1542
|
+
request: IncomingMessage,
|
|
1543
|
+
response: ServerResponse,
|
|
1544
|
+
engine: Engine,
|
|
1545
|
+
ffmpeg: string[],
|
|
1546
|
+
): void {
|
|
1547
|
+
const [command, ...prefix] = ffmpeg as [string, ...string[]];
|
|
1548
|
+
let child: ReturnType<typeof spawn> | null = null;
|
|
1549
|
+
let waiting: ReturnType<typeof setTimeout> | null = null;
|
|
1550
|
+
let closed = false;
|
|
1551
|
+
let started = false;
|
|
1552
|
+
let playing = -1;
|
|
1553
|
+
|
|
1554
|
+
// Held back until the first byte, for the reason transcode() holds it back:
|
|
1555
|
+
// a 200 with nothing behind it is indistinguishable from silence.
|
|
1556
|
+
const begin = (): void => {
|
|
1557
|
+
if (started || closed) return;
|
|
1558
|
+
started = true;
|
|
1559
|
+
response.writeHead(200, {
|
|
1560
|
+
...CORS,
|
|
1561
|
+
"content-type": "audio/mpeg",
|
|
1562
|
+
"cache-control": "no-store",
|
|
1563
|
+
"transfer-encoding": "chunked",
|
|
1564
|
+
});
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
const later = (ms: number, run: () => void): void => {
|
|
1568
|
+
if (waiting) clearTimeout(waiting);
|
|
1569
|
+
waiting = setTimeout(run, ms);
|
|
1570
|
+
waiting.unref?.();
|
|
1571
|
+
};
|
|
1572
|
+
|
|
1573
|
+
const stop = (): void => {
|
|
1574
|
+
if (closed) return;
|
|
1575
|
+
closed = true;
|
|
1576
|
+
if (waiting) clearTimeout(waiting);
|
|
1577
|
+
waiting = null;
|
|
1578
|
+
unsubscribe();
|
|
1579
|
+
child?.kill("SIGKILL");
|
|
1580
|
+
child = null;
|
|
1581
|
+
if (!response.writableEnded) response.end();
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
const next = (): void => {
|
|
1585
|
+
if (closed || child !== null) return;
|
|
1586
|
+
if (waiting) {
|
|
1587
|
+
clearTimeout(waiting);
|
|
1588
|
+
waiting = null;
|
|
1589
|
+
}
|
|
1590
|
+
const snapshot = engine.snapshot();
|
|
1591
|
+
const source = engine.trackPath(snapshot.index);
|
|
1592
|
+
if (source === undefined) {
|
|
1593
|
+
// A playlist that was replaced out from under us, or one that is empty
|
|
1594
|
+
// for the moment. Keep the connection and keep looking.
|
|
1595
|
+
later(LIVE_IDLE_MS, next);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
playing = snapshot.index;
|
|
1600
|
+
const spawned = spawn(
|
|
1601
|
+
command,
|
|
1602
|
+
[
|
|
1603
|
+
...prefix,
|
|
1604
|
+
"-hide_banner",
|
|
1605
|
+
"-loglevel", "error",
|
|
1606
|
+
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
1607
|
+
"-re",
|
|
1608
|
+
"-i", source,
|
|
1609
|
+
"-vn",
|
|
1610
|
+
"-f", "mp3",
|
|
1611
|
+
"-b:a", "192k",
|
|
1612
|
+
"-",
|
|
1613
|
+
],
|
|
1614
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
1615
|
+
);
|
|
1616
|
+
child = spawned;
|
|
1617
|
+
|
|
1618
|
+
spawned.stdout.once("data", begin);
|
|
1619
|
+
spawned.stdout.on("error", () => spawned.kill("SIGKILL"));
|
|
1620
|
+
// end: false, because the response outlives this track. Ending it here is
|
|
1621
|
+
// exactly the bug this endpoint exists to avoid.
|
|
1622
|
+
spawned.stdout.pipe(response, { end: false });
|
|
1623
|
+
spawned.stderr.resume();
|
|
1624
|
+
|
|
1625
|
+
spawned.on("error", stop);
|
|
1626
|
+
spawned.on("close", () => {
|
|
1627
|
+
if (child !== spawned) return;
|
|
1628
|
+
child = null;
|
|
1629
|
+
if (closed) return;
|
|
1630
|
+
// Follow the player if it has already moved on. If it has not, look
|
|
1631
|
+
// again shortly -- which is also what makes a single-track library
|
|
1632
|
+
// repeat rather than fall silent.
|
|
1633
|
+
later(engine.snapshot().index === playing ? LIVE_GAP_MS : 0, next);
|
|
1634
|
+
});
|
|
1635
|
+
};
|
|
1636
|
+
|
|
1637
|
+
// A track change that lands while we are between songs is the signal to go
|
|
1638
|
+
// now rather than wait out the poll.
|
|
1639
|
+
const unsubscribe = engine.subscribe(() => {
|
|
1640
|
+
if (child === null && !closed && engine.snapshot().index !== playing) next();
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
response.on("close", stop);
|
|
1644
|
+
response.on("error", stop);
|
|
1645
|
+
request.on("close", stop);
|
|
1646
|
+
next();
|
|
1647
|
+
}
|
|
1648
|
+
|
|
734
1649
|
/**
|
|
735
1650
|
* Decode anything and hand back MP3, as it is produced.
|
|
736
1651
|
*
|
|
@@ -886,13 +1801,237 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
886
1801
|
|
|
887
1802
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
888
1803
|
const key = options.key ? newKey() : null;
|
|
1804
|
+
// Minted whether or not it is published, so `nixamp admin` and the operator
|
|
1805
|
+
// both have a link they can hand out without handing over the controls.
|
|
1806
|
+
const listenKey = key === null ? null : newKey();
|
|
1807
|
+
// Configuration can arrive from the directory later, so it is a box the
|
|
1808
|
+
// paywall reads rather than a value it was handed once.
|
|
1809
|
+
let paywallConfig: PaywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
|
|
1810
|
+
const connections = new Connections();
|
|
1811
|
+
const paywall = createPaywall({
|
|
1812
|
+
config: () => paywallConfig,
|
|
1813
|
+
liveListeners: () => connections.listening,
|
|
1814
|
+
// The address a payer can actually reach: the public one where there is
|
|
1815
|
+
// one, since a quote pointing at 192.168.1.5 is one they cannot pay from.
|
|
1816
|
+
siteUrl: () => {
|
|
1817
|
+
const bound = server.address();
|
|
1818
|
+
const live = typeof bound === "object" && bound !== null ? bound.port : options.port;
|
|
1819
|
+
const reachable = reachableAddresses(options.host, live);
|
|
1820
|
+
return (reachable.find((a) => a.label === "on the internet") ?? reachable[0])?.url
|
|
1821
|
+
?? `http://127.0.0.1:${live}`;
|
|
1822
|
+
},
|
|
1823
|
+
// The operator drives with the control key, and is not a customer.
|
|
1824
|
+
exempt: (request) =>
|
|
1825
|
+
key !== null && scopeOf(keyFrom(request, new URL(request.url ?? "/", "http://localhost")), key, null) === "control",
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
const channels = new Channels({
|
|
1829
|
+
ffmpeg: tools.ffmpeg,
|
|
1830
|
+
onStart: (info) =>
|
|
1831
|
+
console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
1832
|
+
onEnd: (info) => console.log(` "${info.id}" stopped.`),
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
const destinations = parseDestinations(options.rtmp);
|
|
1836
|
+
const broadcaster = new Broadcaster(tools.ffmpeg);
|
|
1837
|
+
const ingest = options.ingest
|
|
1838
|
+
? new Ingest({
|
|
1839
|
+
ffmpeg: tools.ffmpeg,
|
|
1840
|
+
sink: "pipe:1",
|
|
1841
|
+
onStart: (session) => console.log(` ${session.name} started streaming in (${session.format}).`),
|
|
1842
|
+
onEnd: (session, error) =>
|
|
1843
|
+
console.log(` ${session.name} stopped streaming in${error ? `: ${error}` : ""}.`),
|
|
1844
|
+
})
|
|
1845
|
+
: undefined;
|
|
1846
|
+
|
|
1847
|
+
// The account signed in on this machine owns the server it starts. That is
|
|
1848
|
+
// the whole claim: `nixamp login` then `nixamp serve`, and the phone in your
|
|
1849
|
+
// pocket can administer it from anywhere by signing in as the same person.
|
|
1850
|
+
// Following outlives every stream, so unlike the rest of this it wants a
|
|
1851
|
+
// database. Only where there is one: a nixamp on a laptop has no followers.
|
|
1852
|
+
const pool =
|
|
1853
|
+
options.directory && process.env["DATABASE_URL"]
|
|
1854
|
+
? new pg.Pool({ connectionString: process.env["DATABASE_URL"] })
|
|
1855
|
+
: undefined;
|
|
1856
|
+
const follows = pool ? new Follows(pool) : undefined;
|
|
1857
|
+
// The two things that were promises kept only in memory: a caller who was
|
|
1858
|
+
// told they would be texted, and the ended stream a code still points at.
|
|
1859
|
+
const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
|
|
1860
|
+
|
|
1861
|
+
const vapidPublicKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
|
1862
|
+
const vapidPrivateKey = process.env["VAPID_PRIVATE_KEY"] ?? "";
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* Tell a broadcaster's followers, on whatever they asked to be told on.
|
|
1866
|
+
*
|
|
1867
|
+
* Fired from the directory on the transition to live rather than on every
|
|
1868
|
+
* heartbeat, and awaited by nobody: a publisher's heartbeat should not sit
|
|
1869
|
+
* waiting on a push service.
|
|
1870
|
+
*/
|
|
1871
|
+
const tellFollowers = (listing: Listing): void => {
|
|
1872
|
+
if (follows === undefined || !listing.ownerId) return;
|
|
1873
|
+
const what = listing.nowPlaying ? ` Playing ${listing.nowPlaying}.` : "";
|
|
1874
|
+
const note: Notification = {
|
|
1875
|
+
title: `${listing.name} is live`,
|
|
1876
|
+
body: `${what} Listen at ${DEFAULT_DIRECTORY}/directory, or call ${CALL_IN_NUMBER} and key ${listing.code}.`.trim(),
|
|
1877
|
+
url: listing.url,
|
|
1878
|
+
};
|
|
1879
|
+
void follows
|
|
1880
|
+
.audience(listing.ownerId)
|
|
1881
|
+
.then((audience) =>
|
|
1882
|
+
notifyAll(audience, note, {
|
|
1883
|
+
...(process.env["RESEND_API_KEY"]
|
|
1884
|
+
? {
|
|
1885
|
+
email: resendEmail({
|
|
1886
|
+
apiKey: process.env["RESEND_API_KEY"],
|
|
1887
|
+
from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
|
|
1888
|
+
onEvent: (message) => console.log(message),
|
|
1889
|
+
}),
|
|
1890
|
+
}
|
|
1891
|
+
: {}),
|
|
1892
|
+
...(process.env["TELNYX_API_KEY"] && process.env["PARTYLINE_SMS_FROM"]
|
|
1893
|
+
? {
|
|
1894
|
+
sms: telnyxSms({
|
|
1895
|
+
apiKey: process.env["TELNYX_API_KEY"],
|
|
1896
|
+
from: process.env["PARTYLINE_SMS_FROM"],
|
|
1897
|
+
onEvent: (message) => console.log(message),
|
|
1898
|
+
}),
|
|
1899
|
+
}
|
|
1900
|
+
: {}),
|
|
1901
|
+
...(vapidPublicKey && vapidPrivateKey
|
|
1902
|
+
? {
|
|
1903
|
+
push: webPush({
|
|
1904
|
+
publicKey: vapidPublicKey,
|
|
1905
|
+
privateKey: vapidPrivateKey,
|
|
1906
|
+
subject: process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY,
|
|
1907
|
+
onEvent: (message) => console.log(message),
|
|
1908
|
+
}),
|
|
1909
|
+
}
|
|
1910
|
+
: {}),
|
|
1911
|
+
// A subscription the vendor has retired is a row to delete, not a
|
|
1912
|
+
// failure to retry.
|
|
1913
|
+
onGone: (endpoint) => follows.removePush(endpoint),
|
|
1914
|
+
onEvent: (message) => console.log(message),
|
|
1915
|
+
}),
|
|
1916
|
+
)
|
|
1917
|
+
.catch(() => {});
|
|
1918
|
+
};
|
|
1919
|
+
|
|
1920
|
+
// Hoisted rather than built inline, because the party line needs the same
|
|
1921
|
+
// instance: a second Directory would be a second set of stream codes, and
|
|
1922
|
+
// the one the phone looked in would never be the one the publishers reach.
|
|
1923
|
+
const directory = options.directory
|
|
1924
|
+
? new Directory(undefined, undefined, undefined, tellFollowers)
|
|
1925
|
+
: undefined;
|
|
1926
|
+
|
|
1927
|
+
if (directory && durable) {
|
|
1928
|
+
// Echoed rather than awaited: the directory answers from memory, so a
|
|
1929
|
+
// database that is briefly unreachable should cost the durability and not
|
|
1930
|
+
// the request.
|
|
1931
|
+
directory.persistTo({
|
|
1932
|
+
save: (item) => void durable.saveEnded(item),
|
|
1933
|
+
drop: (id) => void durable.dropEnded(id),
|
|
1934
|
+
});
|
|
1935
|
+
// And put back what the last process knew, without holding up the listen.
|
|
1936
|
+
void durable
|
|
1937
|
+
.loadEnded(Date.now() - ENDED_TTL_MS)
|
|
1938
|
+
.then((items) => {
|
|
1939
|
+
if (items.length > 0) console.log(` remembered ${items.length} stream(s) that had ended.`);
|
|
1940
|
+
directory.seedEnded(items);
|
|
1941
|
+
})
|
|
1942
|
+
.catch(() => {});
|
|
1943
|
+
void durable.sweep(Date.now() - ENDED_TTL_MS, new Date(Date.now() - 30 * 24 * 60 * 60 * 1000));
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const session = readSession();
|
|
1947
|
+
const owner = new Owner({
|
|
1948
|
+
ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
|
|
1949
|
+
site: session?.site ?? DEFAULT_DIRECTORY,
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
// The party line answers a phone number, and there is only one number. Both
|
|
1953
|
+
// keys or neither: without the public key every webhook would be refused,
|
|
1954
|
+
// which is a worse failure than not offering the endpoint.
|
|
1955
|
+
const partyLine =
|
|
1956
|
+
options.directory && process.env["TELNYX_API_KEY"] && process.env["TELNYX_PUBLIC_KEY"]
|
|
1957
|
+
? new PartyLine({
|
|
1958
|
+
apiKey: process.env["TELNYX_API_KEY"],
|
|
1959
|
+
publicKey: process.env["TELNYX_PUBLIC_KEY"],
|
|
1960
|
+
streams: directory,
|
|
1961
|
+
callIn: CALL_IN_NUMBER,
|
|
1962
|
+
// Only when a sending number is configured. Without one the line
|
|
1963
|
+
// still answers and still says when the stream ended; it just does
|
|
1964
|
+
// not offer a text it could not send.
|
|
1965
|
+
...(process.env["PARTYLINE_SMS_FROM"]
|
|
1966
|
+
? {
|
|
1967
|
+
sms: telnyxSms({
|
|
1968
|
+
apiKey: process.env["TELNYX_API_KEY"],
|
|
1969
|
+
from: process.env["PARTYLINE_SMS_FROM"],
|
|
1970
|
+
onEvent: (message) => console.log(message),
|
|
1971
|
+
}),
|
|
1972
|
+
}
|
|
1973
|
+
: {}),
|
|
1974
|
+
...(process.env["PARTYLINE_GREETING"] ? { greeting: process.env["PARTYLINE_GREETING"] } : {}),
|
|
1975
|
+
...(process.env["PARTYLINE_VOICE"] ? { voice: process.env["PARTYLINE_VOICE"] } : {}),
|
|
1976
|
+
onEvent: (message) => console.log(message),
|
|
1977
|
+
})
|
|
1978
|
+
: undefined;
|
|
1979
|
+
|
|
1980
|
+
if (partyLine && durable) {
|
|
1981
|
+
// Put back everybody a previous process promised to text, then keep
|
|
1982
|
+
// echoing. Seeding first means a stream that goes live during startup
|
|
1983
|
+
// still finds them.
|
|
1984
|
+
void durable
|
|
1985
|
+
.loadReminders()
|
|
1986
|
+
.then((waiting) => {
|
|
1987
|
+
const owed = [...waiting.values()].reduce((n, set) => n + set.size, 0);
|
|
1988
|
+
if (owed > 0) console.log(` ${owed} caller(s) are still owed a text.`);
|
|
1989
|
+
partyLine.persistRemindersTo(
|
|
1990
|
+
{
|
|
1991
|
+
add: (code, phone) => void durable.addReminder(code, phone),
|
|
1992
|
+
take: (code) => durable.takeReminders(code),
|
|
1993
|
+
},
|
|
1994
|
+
waiting,
|
|
1995
|
+
);
|
|
1996
|
+
})
|
|
1997
|
+
.catch(() => {
|
|
1998
|
+
// Still worth echoing new ones even if the old list could not be read.
|
|
1999
|
+
partyLine.persistRemindersTo({
|
|
2000
|
+
add: (code, phone) => void durable.addReminder(code, phone),
|
|
2001
|
+
take: (code) => durable.takeReminders(code),
|
|
2002
|
+
});
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
|
|
889
2006
|
const server = createServer(engine, {
|
|
890
2007
|
web,
|
|
891
2008
|
media: options.media,
|
|
2009
|
+
owner,
|
|
2010
|
+
channels,
|
|
2011
|
+
...(ingest ? { ingest } : {}),
|
|
2012
|
+
broadcaster,
|
|
2013
|
+
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
892
2014
|
version,
|
|
893
2015
|
key,
|
|
2016
|
+
listenKey,
|
|
2017
|
+
connections,
|
|
2018
|
+
paywall,
|
|
894
2019
|
ffmpeg: tools.ffmpeg,
|
|
895
2020
|
load: (next) => loadSource(tools, next),
|
|
2021
|
+
...(directory ? { directory } : {}),
|
|
2022
|
+
...(follows ? { follows, vapidPublicKey } : {}),
|
|
2023
|
+
...(partyLine ? { partyLine } : {}),
|
|
2024
|
+
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
2025
|
+
// laptop has nobody to be an account of.
|
|
2026
|
+
...(options.directory && process.env["DATABASE_URL"]
|
|
2027
|
+
? {
|
|
2028
|
+
accounts: new Accounts({
|
|
2029
|
+
connectionString: process.env["DATABASE_URL"],
|
|
2030
|
+
secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
|
|
2031
|
+
}),
|
|
2032
|
+
secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
|
|
2033
|
+
}
|
|
2034
|
+
: {}),
|
|
896
2035
|
});
|
|
897
2036
|
|
|
898
2037
|
// A port already in use is the most ordinary failure there is, and it
|
|
@@ -944,6 +2083,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
944
2083
|
} else {
|
|
945
2084
|
console.log(" Open that link once on a phone or a laptop and it stays signed in.");
|
|
946
2085
|
console.log(` Anything without the key gets a 401. Key: ${key}`);
|
|
2086
|
+
if (listenKey !== null) {
|
|
2087
|
+
console.log("");
|
|
2088
|
+
console.log(" A listen-only link, for someone you want to hear it but not drive it:");
|
|
2089
|
+
for (const { label, url } of addresses) {
|
|
2090
|
+
if (label === "here") continue;
|
|
2091
|
+
console.log(` ${shareLink(url, listenKey)}`);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
947
2094
|
}
|
|
948
2095
|
if (addresses.some((a) => a.label === "on the internet")) {
|
|
949
2096
|
console.log("");
|
|
@@ -954,6 +2101,31 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
954
2101
|
);
|
|
955
2102
|
}
|
|
956
2103
|
if (!options.media) console.log(" Audio stays on this machine: --no-media is set.");
|
|
2104
|
+
if (owner.claimed) {
|
|
2105
|
+
console.log(` ${session?.email} can administer this from anywhere, signed in at ${session?.site}.`);
|
|
2106
|
+
}
|
|
2107
|
+
if (options.ingest) console.log(" Accepting a live stream in at POST /api/ingest.");
|
|
2108
|
+
let rtmp: RtmpListeners | null = null;
|
|
2109
|
+
if (options.rtmpIn > 0) {
|
|
2110
|
+
const publish = addresses.find((a) => a.label !== "here") ?? addresses[0];
|
|
2111
|
+
const host = publish ? new URL(publish.url).hostname : "127.0.0.1";
|
|
2112
|
+
// One listener per stream, because ffmpeg's RTMP listener serves a single
|
|
2113
|
+
// connection per process. Three devices going live at once is three ports.
|
|
2114
|
+
const slots = Array.from({ length: options.rtmpStreams }, (_, i) => ({
|
|
2115
|
+
port: options.rtmpIn + i,
|
|
2116
|
+
id: i === 0 ? "live" : `live-${i + 1}`,
|
|
2117
|
+
}));
|
|
2118
|
+
rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
|
|
2119
|
+
rtmp.listen(slots);
|
|
2120
|
+
|
|
2121
|
+
console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
|
|
2122
|
+
for (const slot of slots) {
|
|
2123
|
+
console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
if (destinations.length > 0) {
|
|
2127
|
+
console.log(` Ready to broadcast to ${destinations.map((d) => d.name).join(", ")}.`);
|
|
2128
|
+
}
|
|
957
2129
|
if (web === null) console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
|
|
958
2130
|
|
|
959
2131
|
// Listening on every interface proves the socket is open here and nothing
|
|
@@ -994,7 +2166,69 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
994
2166
|
}
|
|
995
2167
|
}
|
|
996
2168
|
|
|
2169
|
+
// The listing carries the listen link, and only ever a public address: an
|
|
2170
|
+
// entry pointing at 192.168.1.5 is one nobody outside that house can open.
|
|
2171
|
+
const publishable_ = addresses.find((a) => a.label === "on the internet")
|
|
2172
|
+
?? addresses.find((a) => a.label === "on tailscale");
|
|
2173
|
+
let publisher: Publisher | null = null;
|
|
2174
|
+
|
|
2175
|
+
if (options.publish !== "no" && publishable_) {
|
|
2176
|
+
const listen = shareLink(publishable_.url, listenKey);
|
|
2177
|
+
// Announced next to the listen link, not instead of it: one is for a person
|
|
2178
|
+
// with a browser, the other for the phone line and anything else that is
|
|
2179
|
+
// handed one address and expected to play it.
|
|
2180
|
+
const audio = audioLink(publishable_.url, listenKey);
|
|
2181
|
+
const wanted = options.publish === "yes"
|
|
2182
|
+
? true
|
|
2183
|
+
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
2184
|
+
|
|
2185
|
+
if (wanted) {
|
|
2186
|
+
publisher = new Publisher({
|
|
2187
|
+
directory: DEFAULT_DIRECTORY,
|
|
2188
|
+
name: options.name || hostname(),
|
|
2189
|
+
url: listen,
|
|
2190
|
+
audio,
|
|
2191
|
+
tracks: tracks.length,
|
|
2192
|
+
// From `nixamp login`. The directory will not list a stream it cannot
|
|
2193
|
+
// attribute to somebody, because a listing is now a phone code that
|
|
2194
|
+
// costs money to answer.
|
|
2195
|
+
...(session?.token ? { token: session.token } : {}),
|
|
2196
|
+
onRefused: () => {
|
|
2197
|
+
console.log("");
|
|
2198
|
+
console.log(" nixamp.com would not list this stream: it needs an account.");
|
|
2199
|
+
console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
|
|
2200
|
+
},
|
|
2201
|
+
nowPlaying: () => {
|
|
2202
|
+
const snapshot = engine.snapshot();
|
|
2203
|
+
return snapshot.tracks[snapshot.index]?.title ?? "";
|
|
2204
|
+
},
|
|
2205
|
+
onConfig: (remote) => {
|
|
2206
|
+
const next = applyRemoteConfig(paywallConfig, (remote as { x402?: unknown })?.x402);
|
|
2207
|
+
if (JSON.stringify(next) === JSON.stringify(paywallConfig)) return;
|
|
2208
|
+
paywallConfig = next;
|
|
2209
|
+
console.log(next.enabled
|
|
2210
|
+
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
2211
|
+
: " nixamp.com turned paid listening off.");
|
|
2212
|
+
},
|
|
2213
|
+
});
|
|
2214
|
+
const listing = await publisher.start();
|
|
2215
|
+
console.log("");
|
|
2216
|
+
console.log(listing
|
|
2217
|
+
? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
|
|
2218
|
+
: ` Could not reach ${DEFAULT_DIRECTORY}; not listed.`);
|
|
2219
|
+
}
|
|
2220
|
+
} else if (options.publish === "yes" && !publishable_) {
|
|
2221
|
+
console.log("");
|
|
2222
|
+
console.log(" --publish needs an address the world can reach. This machine has none.");
|
|
2223
|
+
}
|
|
2224
|
+
|
|
997
2225
|
const shutdown = (): void => {
|
|
2226
|
+
rtmp?.stop();
|
|
2227
|
+
channels.stopAll();
|
|
2228
|
+
ingest?.stopRtmp();
|
|
2229
|
+
ingest?.close();
|
|
2230
|
+
broadcaster.stop();
|
|
2231
|
+
void publisher?.stop();
|
|
998
2232
|
closePort?.();
|
|
999
2233
|
engine.stop();
|
|
1000
2234
|
server.close(() => process.exit(0));
|
|
@@ -1005,11 +2239,69 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
1005
2239
|
process.on("SIGTERM", shutdown);
|
|
1006
2240
|
}
|
|
1007
2241
|
|
|
2242
|
+
/**
|
|
2243
|
+
* `--rtmp youtube=<key>` or `--rtmp name=rtmp://host/app/key`.
|
|
2244
|
+
*
|
|
2245
|
+
* A key is a password, so it is taken from the command line or the environment
|
|
2246
|
+
* and never from a request: a client that could name its own destination could
|
|
2247
|
+
* point your broadcast at itself.
|
|
2248
|
+
*/
|
|
2249
|
+
export function parseDestinations(specs: string[]): Destination[] {
|
|
2250
|
+
const out: Destination[] = [];
|
|
2251
|
+
for (const [index, spec] of specs.entries()) {
|
|
2252
|
+
const at = spec.indexOf("=");
|
|
2253
|
+
if (at <= 0) continue;
|
|
2254
|
+
const name = spec.slice(0, at).trim();
|
|
2255
|
+
const rest = spec.slice(at + 1).trim();
|
|
2256
|
+
if (!name || !rest) continue;
|
|
2257
|
+
|
|
2258
|
+
const preset = PRESETS[name.toLowerCase()];
|
|
2259
|
+
if (preset && !/^rtmps?:\/\//i.test(rest)) {
|
|
2260
|
+
out.push({ id: String(index + 1), name, url: preset, key: rest, enabled: true });
|
|
2261
|
+
continue;
|
|
2262
|
+
}
|
|
2263
|
+
if (!/^rtmps?:\/\//i.test(rest)) continue;
|
|
2264
|
+
|
|
2265
|
+
// A full URL: the last path segment is the key.
|
|
2266
|
+
const cut = rest.lastIndexOf("/");
|
|
2267
|
+
if (cut <= "rtmp://".length) continue;
|
|
2268
|
+
out.push({
|
|
2269
|
+
id: String(index + 1),
|
|
2270
|
+
name,
|
|
2271
|
+
url: rest.slice(0, cut),
|
|
2272
|
+
key: rest.slice(cut + 1),
|
|
2273
|
+
enabled: true,
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
return out;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/**
|
|
2280
|
+
* Which account the signed-in session belongs to. Asked once at startup rather
|
|
2281
|
+
* than trusted from the file: a token that nixamp.com no longer accepts should
|
|
2282
|
+
* not confer ownership of anything.
|
|
2283
|
+
*/
|
|
2284
|
+
async function ownerIdOf(session: { site: string; token: string }): Promise<string> {
|
|
2285
|
+
try {
|
|
2286
|
+
const answer = await fetch(`${session.site}/api/v1/auth/me`, {
|
|
2287
|
+
headers: { authorization: `Bearer ${session.token}` },
|
|
2288
|
+
});
|
|
2289
|
+
if (!answer.ok) return "";
|
|
2290
|
+
const body = (await answer.json()) as { account?: { id?: string } };
|
|
2291
|
+
return typeof body.account?.id === "string" ? body.account.id : "";
|
|
2292
|
+
} catch {
|
|
2293
|
+
// Offline at startup means no remote administration until a restart, and
|
|
2294
|
+
// the control key still works. Better than claiming an owner we cannot
|
|
2295
|
+
// check.
|
|
2296
|
+
return "";
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
1008
2300
|
/** The built PWA, when it is sitting next to us in the same install. */
|
|
1009
2301
|
function defaultWebDir(): string | null {
|
|
1010
2302
|
const fromEnv = process.env.NIXAMP_WEB_DIR;
|
|
1011
2303
|
if (fromEnv && isFile(join(fromEnv, "index.html"))) return fromEnv;
|
|
1012
|
-
const here = new URL(".", import.meta.url)
|
|
2304
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
1013
2305
|
for (const guess of [
|
|
1014
2306
|
join(here, "..", "web", "dist"),
|
|
1015
2307
|
join(here, "..", "..", "web", "dist"),
|