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.
- 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 +63 -0
- package/dist/directory.js +111 -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/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/publish.d.ts +36 -0
- package/dist/publish.js +90 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +79 -0
- package/dist/server.js +609 -10
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +16 -0
- package/dist/share.js +19 -0
- package/package.json +5 -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 +135 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/owner.ts +113 -0
- package/src/paywall.ts +198 -0
- package/src/publish.ts +101 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +702 -10
- package/src/session.ts +209 -0
- package/src/share.ts +27 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
- package/web/dist/assets/index-WYJ6R4uF.js +1 -0
- package/web/dist/index.html +37 -6
- package/web/dist/sw.js +3 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/dist/server.js
CHANGED
|
@@ -11,12 +11,24 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { createReadStream, statSync } from "node:fs";
|
|
13
13
|
import { createServer as createHttpServer } from "node:http";
|
|
14
|
+
import { hostname } from "node:os";
|
|
14
15
|
import { spawn, spawnSync } from "node:child_process";
|
|
15
16
|
import { readFileSync } from "node:fs";
|
|
16
17
|
import { Connections } from "./connections.js";
|
|
18
|
+
import { Broadcaster, DEFAULT_ENCODER, PRESETS, redact, } from "./broadcast.js";
|
|
19
|
+
import { Ingest, normaliseFormat } from "./ingest.js";
|
|
20
|
+
import { Channels, cleanId } from "./channels.js";
|
|
21
|
+
import { RtmpListeners } from "./rtmp-in.js";
|
|
22
|
+
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
|
|
23
|
+
import { needsAdmin, Owner } from "./owner.js";
|
|
24
|
+
import { readSession } from "./session.js";
|
|
25
|
+
import { Directory, parseAnnouncement } from "./directory.js";
|
|
26
|
+
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
27
|
+
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
17
28
|
import { isRemote } from "./sources.js";
|
|
18
|
-
import { elevate, firewallInUse, keyCookie, keyFrom,
|
|
29
|
+
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, newKey, portCommands, reachableAddresses, scopeOf, shareLink, } from "./share.js";
|
|
19
30
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
20
32
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
21
33
|
import { Analyser, bandEdges, bands, decay } from "./fft.js";
|
|
22
34
|
import { loadSource } from "./playlist.js";
|
|
@@ -43,6 +55,15 @@ export function parseServeArgs(argv) {
|
|
|
43
55
|
key: true,
|
|
44
56
|
openPort: false,
|
|
45
57
|
announce: false,
|
|
58
|
+
directory: false,
|
|
59
|
+
publish: "ask",
|
|
60
|
+
name: "",
|
|
61
|
+
x402: false,
|
|
62
|
+
owner: "",
|
|
63
|
+
ingest: false,
|
|
64
|
+
rtmpIn: 0,
|
|
65
|
+
rtmpStreams: 3,
|
|
66
|
+
rtmp: [],
|
|
46
67
|
};
|
|
47
68
|
let sawRoot = false;
|
|
48
69
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -79,6 +100,49 @@ export function parseServeArgs(argv) {
|
|
|
79
100
|
else if (arg === "--announce") {
|
|
80
101
|
options.announce = true;
|
|
81
102
|
}
|
|
103
|
+
else if (arg === "--directory") {
|
|
104
|
+
options.directory = true;
|
|
105
|
+
}
|
|
106
|
+
else if (arg === "--publish") {
|
|
107
|
+
options.publish = "yes";
|
|
108
|
+
}
|
|
109
|
+
else if (arg === "--no-publish") {
|
|
110
|
+
options.publish = "no";
|
|
111
|
+
}
|
|
112
|
+
else if (arg === "--name") {
|
|
113
|
+
options.name = value();
|
|
114
|
+
}
|
|
115
|
+
else if (arg === "--owner") {
|
|
116
|
+
options.owner = value();
|
|
117
|
+
}
|
|
118
|
+
else if (arg === "--ingest") {
|
|
119
|
+
options.ingest = true;
|
|
120
|
+
}
|
|
121
|
+
else if (arg === "--rtmp-streams") {
|
|
122
|
+
const count = Number(value());
|
|
123
|
+
if (!Number.isInteger(count) || count < 1 || count > 16) {
|
|
124
|
+
throw new Error("nixamp serve: --rtmp-streams must be between 1 and 16");
|
|
125
|
+
}
|
|
126
|
+
options.rtmpStreams = count;
|
|
127
|
+
}
|
|
128
|
+
else if (arg === "--rtmp-in") {
|
|
129
|
+
const port = Number(value());
|
|
130
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
131
|
+
throw new Error("nixamp serve: --rtmp-in must be a port number");
|
|
132
|
+
}
|
|
133
|
+
options.rtmpIn = port;
|
|
134
|
+
// Listening for RTMP is accepting a live stream, so it implies --ingest.
|
|
135
|
+
options.ingest = true;
|
|
136
|
+
}
|
|
137
|
+
else if (arg === "--rtmp") {
|
|
138
|
+
options.rtmp.push(value());
|
|
139
|
+
}
|
|
140
|
+
else if (arg === "--x402") {
|
|
141
|
+
options.x402 = true;
|
|
142
|
+
}
|
|
143
|
+
else if (arg === "--no-x402") {
|
|
144
|
+
options.x402 = false;
|
|
145
|
+
}
|
|
82
146
|
else if (arg.startsWith("-")) {
|
|
83
147
|
throw new Error(`nixamp serve: unknown option ${arg}`);
|
|
84
148
|
}
|
|
@@ -449,38 +513,367 @@ export function createHandler(engine, options) {
|
|
|
449
513
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
450
514
|
const path = url.pathname;
|
|
451
515
|
const key = options.key ?? null;
|
|
516
|
+
const listenKey = options.listenKey ?? null;
|
|
452
517
|
if (request.method === "OPTIONS") {
|
|
453
518
|
response.writeHead(204, CORS);
|
|
454
519
|
response.end();
|
|
455
520
|
return;
|
|
456
521
|
}
|
|
457
|
-
// Opening
|
|
458
|
-
//
|
|
459
|
-
// without the page knowing anything about keys.
|
|
522
|
+
// Opening a share link is what hands a browser its key. It comes back as a
|
|
523
|
+
// cookie, so every later fetch, EventSource and <audio src> carries it
|
|
524
|
+
// without the page knowing anything about keys. Either key works here, and
|
|
525
|
+
// which one was used decides what the browser can then do.
|
|
460
526
|
if (key !== null && path.startsWith("/s/")) {
|
|
461
527
|
const offered = decodeURIComponent(path.slice("/s/".length));
|
|
462
|
-
if (
|
|
528
|
+
if (scopeOf(offered, key, listenKey) === null) {
|
|
463
529
|
json(response, 404, { error: "not found" });
|
|
464
530
|
return;
|
|
465
531
|
}
|
|
466
|
-
response.writeHead(302, { ...CORS, "set-cookie": keyCookie(
|
|
532
|
+
response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
|
|
467
533
|
response.end();
|
|
468
534
|
return;
|
|
469
535
|
}
|
|
470
536
|
// /api/health answers unauthenticated on purpose: it is how you check the
|
|
471
537
|
// port is open from another device before wondering whether the link is
|
|
472
538
|
// wrong, and it says nothing about the library.
|
|
473
|
-
if (key !== null &&
|
|
474
|
-
|
|
475
|
-
|
|
539
|
+
if (key !== null &&
|
|
540
|
+
path !== "/api/health" &&
|
|
541
|
+
path !== "/api/directory" &&
|
|
542
|
+
!path.startsWith("/api/v1/auth/")) {
|
|
543
|
+
const scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
544
|
+
if (scope === null) {
|
|
476
545
|
json(response, 401, { error: "this nixamp needs the key from its share link" });
|
|
477
546
|
return;
|
|
478
547
|
}
|
|
548
|
+
if (scope === "listen" && !allowedForListening(path)) {
|
|
549
|
+
json(response, 403, { error: "this link can listen, not drive" });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
479
552
|
}
|
|
480
553
|
if (path === "/api/health") {
|
|
481
554
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
482
555
|
return;
|
|
483
556
|
}
|
|
557
|
+
// --- accounts ---------------------------------------------------------
|
|
558
|
+
//
|
|
559
|
+
// Before the share-key check, because signing in is how somebody without a
|
|
560
|
+
// key becomes somebody with one. The API is versioned and namespaced the
|
|
561
|
+
// way the rest of the fleet's is.
|
|
562
|
+
if (path.startsWith("/api/v1/auth/") && options.accounts) {
|
|
563
|
+
const accounts = options.accounts;
|
|
564
|
+
const secure = options.secureCookies ?? false;
|
|
565
|
+
if (path === "/api/v1/auth/me") {
|
|
566
|
+
const who = await accounts.whoIs(tokenFrom(request.headers));
|
|
567
|
+
if (who === null) {
|
|
568
|
+
json(response, 401, { error: "not signed in" });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
json(response, 200, { account: who });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (path === "/api/v1/auth/logout") {
|
|
575
|
+
response.writeHead(200, {
|
|
576
|
+
...CORS,
|
|
577
|
+
"content-type": "application/json; charset=utf-8",
|
|
578
|
+
"set-cookie": clearedCookie(),
|
|
579
|
+
});
|
|
580
|
+
response.end(JSON.stringify({ ok: true }));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const signingUp = path === "/api/v1/auth/signup";
|
|
584
|
+
if (!signingUp && path !== "/api/v1/auth/login") {
|
|
585
|
+
json(response, 404, { error: "no such endpoint" });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (request.method !== "POST") {
|
|
589
|
+
json(response, 405, { error: "POST only" });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
let body;
|
|
593
|
+
try {
|
|
594
|
+
body = JSON.parse(await readBody(request));
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
json(response, 400, { error: "bad JSON" });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const result = signingUp
|
|
601
|
+
? await accounts.signUp(body.email, body.password)
|
|
602
|
+
: await accounts.signIn(body.email, body.password);
|
|
603
|
+
if (!result.ok) {
|
|
604
|
+
// 409 for an address that is taken, 401 for credentials that are not.
|
|
605
|
+
json(response, signingUp ? 409 : 401, { error: result.error });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
// The token goes back in the body for the CLI and the desktop app, and
|
|
609
|
+
// as a cookie for the browser, which then needs to know nothing about it.
|
|
610
|
+
response.writeHead(200, {
|
|
611
|
+
...CORS,
|
|
612
|
+
"content-type": "application/json; charset=utf-8",
|
|
613
|
+
"set-cookie": sessionCookie(result.token, secure),
|
|
614
|
+
});
|
|
615
|
+
response.end(JSON.stringify({ account: result.account, token: result.token }));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
// The directory is public in both directions: anyone may read the list,
|
|
619
|
+
// and anyone running a nixamp may add themselves to it. It is answered
|
|
620
|
+
// before the key check, because a visitor to nixamp.com has no key and is
|
|
621
|
+
// exactly who it is for.
|
|
622
|
+
if (path === "/api/directory" && options.directory) {
|
|
623
|
+
if (request.method === "GET") {
|
|
624
|
+
json(response, 200, { streams: options.directory.list(), now: Date.now() });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (request.method === "POST") {
|
|
628
|
+
let announcement;
|
|
629
|
+
try {
|
|
630
|
+
announcement = parseAnnouncement(JSON.parse(await readBody(request)));
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
json(response, 400, { error: "bad JSON" });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (announcement === null) {
|
|
637
|
+
json(response, 422, { error: "a listing needs a name and a URL a browser can reach" });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
json(response, 200, options.directory.announce(announcement));
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (request.method === "DELETE") {
|
|
644
|
+
const id = url.searchParams.get("id");
|
|
645
|
+
if (id)
|
|
646
|
+
options.directory.withdraw(id);
|
|
647
|
+
json(response, 200, { ok: true });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
// Administering is a different question from listening, and it is asked
|
|
654
|
+
// after the share key: the control key answers both, but a listen key or a
|
|
655
|
+
// nixamp.com session answers only one of them.
|
|
656
|
+
if (options.owner && needsAdmin(path, request.method ?? "GET")) {
|
|
657
|
+
// A server started with --no-key has said that anyone who can reach the
|
|
658
|
+
// port may drive it, and prints exactly that. Locking administration to
|
|
659
|
+
// nobody would contradict it and leave such a server unadministrable.
|
|
660
|
+
const holdsControl = key === null || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
661
|
+
const check = await options.owner.check(holdsControl, tokenFrom(request.headers));
|
|
662
|
+
if (path === "/api/admin") {
|
|
663
|
+
// Always answered, and honestly: the page has to know whether to draw
|
|
664
|
+
// an admin panel at all, and "no" is a real answer rather than a 403.
|
|
665
|
+
json(response, 200, { allowed: check.allowed, as: check.as, claimed: options.owner.claimed });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (!check.allowed) {
|
|
669
|
+
json(response, 403, {
|
|
670
|
+
error: options.owner.claimed
|
|
671
|
+
? "sign in to nixamp.com as this server's owner, or use its control link"
|
|
672
|
+
: "this server has no owner signed in; use its control link",
|
|
673
|
+
});
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// After the key check: a paying listener still needs the link, and a 402
|
|
678
|
+
// is a worse answer than a 401 to someone who has neither.
|
|
679
|
+
if (options.paywall && (await options.paywall(request, response, path)))
|
|
680
|
+
return;
|
|
681
|
+
// --- several streams at once ------------------------------------------
|
|
682
|
+
//
|
|
683
|
+
// A channel is one publisher and everybody listening to them. Two or three
|
|
684
|
+
// devices can publish at once, each to their own channel, and a listener
|
|
685
|
+
// picks which to hear.
|
|
686
|
+
if (path === "/api/channels" && options.channels) {
|
|
687
|
+
json(response, 200, { channels: options.channels.list(), listeners: options.channels.listeners });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
// Publishing. Anyone with the control link may; listening to the result is
|
|
691
|
+
// open to whoever has the share link, like the rest of the audio.
|
|
692
|
+
if (path.startsWith("/api/channels/") && options.channels) {
|
|
693
|
+
const channels = options.channels;
|
|
694
|
+
const rest = path.slice("/api/channels/".length);
|
|
695
|
+
const [rawId, action] = rest.split("/");
|
|
696
|
+
const id = cleanId(rawId);
|
|
697
|
+
if (action === undefined && request.method === "GET") {
|
|
698
|
+
// Listening. The response is the fan-out target: whatever ffmpeg
|
|
699
|
+
// produces for this channel is written to it until one end goes away.
|
|
700
|
+
const detach = channels.listen(id, response);
|
|
701
|
+
if (detach === null) {
|
|
702
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
watch(request, response, "stream", id);
|
|
706
|
+
response.writeHead(200, {
|
|
707
|
+
...CORS,
|
|
708
|
+
"content-type": "audio/mpeg",
|
|
709
|
+
"cache-control": "no-store",
|
|
710
|
+
});
|
|
711
|
+
const leave = () => detach();
|
|
712
|
+
request.on("close", leave);
|
|
713
|
+
response.on("close", leave);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (action === undefined && request.method === "DELETE") {
|
|
717
|
+
// Asked once: the second call would answer false, having just stopped
|
|
718
|
+
// the thing it was asking about.
|
|
719
|
+
const stopped = channels.stop(id);
|
|
720
|
+
json(response, stopped ? 200 : 404, { ok: stopped });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (request.method !== "POST") {
|
|
724
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
728
|
+
if (format === null) {
|
|
729
|
+
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const name = url.searchParams.get("name") ?? "";
|
|
733
|
+
// A chunked publisher sends many requests to one channel, so the first
|
|
734
|
+
// claims it and the rest feed what is already there.
|
|
735
|
+
if (action === "chunk") {
|
|
736
|
+
if (!channels.has(id) && channels.publish(id, name, format, "http") === null) {
|
|
737
|
+
json(response, 409, { error: "that channel is already being published to" });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const chunks = [];
|
|
741
|
+
for await (const chunk of request)
|
|
742
|
+
chunks.push(chunk);
|
|
743
|
+
channels.writeTo(id, Buffer.concat(chunks));
|
|
744
|
+
json(response, 200, { ok: true });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const claimed = channels.publish(id, name, format, "http");
|
|
748
|
+
if (claimed === null) {
|
|
749
|
+
json(response, 409, { error: "that channel is already being published to" });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
await claimed.pump(request);
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
// A publisher that hung up is not an error worth a 500.
|
|
757
|
+
}
|
|
758
|
+
claimed.close();
|
|
759
|
+
json(response, 200, { ok: true, bytes: claimed.info.bytes });
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
// --- streaming in ---------------------------------------------------
|
|
763
|
+
//
|
|
764
|
+
// Both shapes write into the same ffmpeg, so everything downstream cannot
|
|
765
|
+
// tell which one a sender used.
|
|
766
|
+
if (path === "/api/ingest" && options.ingest) {
|
|
767
|
+
if (request.method === "GET") {
|
|
768
|
+
json(response, 200, options.ingest.status());
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (request.method === "DELETE") {
|
|
772
|
+
options.ingest.close();
|
|
773
|
+
json(response, 200, { ok: true });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (request.method !== "POST") {
|
|
777
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
781
|
+
if (format === null) {
|
|
782
|
+
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const session = options.ingest.open(url.searchParams.get("name") ?? "", format);
|
|
786
|
+
if (session === null) {
|
|
787
|
+
// Somebody else is already broadcasting, which is a different problem
|
|
788
|
+
// from the request being wrong.
|
|
789
|
+
json(response, 409, { error: "something is already streaming in" });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
await options.ingest.pump(request);
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
// A sender that hung up is not an error worth a 500.
|
|
797
|
+
}
|
|
798
|
+
options.ingest.close();
|
|
799
|
+
json(response, 200, { ok: true, bytes: session.bytes });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// A browser cannot stream a request body over plain HTTP/1.1, so a phone
|
|
803
|
+
// sends its recording a chunk at a time instead.
|
|
804
|
+
if (path === "/api/ingest/chunk" && options.ingest) {
|
|
805
|
+
if (request.method !== "POST") {
|
|
806
|
+
json(response, 405, { error: "POST only" });
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (!options.ingest.live) {
|
|
810
|
+
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
811
|
+
if (format === null) {
|
|
812
|
+
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (options.ingest.open(url.searchParams.get("name") ?? "", format) === null) {
|
|
816
|
+
json(response, 409, { error: "something is already streaming in" });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const chunks = [];
|
|
821
|
+
for await (const chunk of request)
|
|
822
|
+
chunks.push(chunk);
|
|
823
|
+
options.ingest.write(Buffer.concat(chunks));
|
|
824
|
+
json(response, 200, options.ingest.status());
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
// --- broadcasting out -------------------------------------------------
|
|
828
|
+
if (path === "/api/broadcast" && options.broadcaster) {
|
|
829
|
+
if (request.method === "GET") {
|
|
830
|
+
json(response, 200, options.broadcaster.status());
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (request.method === "DELETE") {
|
|
834
|
+
options.broadcaster.stop();
|
|
835
|
+
json(response, 200, options.broadcaster.status());
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (request.method !== "POST") {
|
|
839
|
+
json(response, 405, { error: "GET, POST or DELETE" });
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
let source = "";
|
|
843
|
+
try {
|
|
844
|
+
source = String(JSON.parse(await readBody(request)).source ?? "");
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
json(response, 400, { error: "bad JSON" });
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const current = engine.snapshot();
|
|
851
|
+
const chosen = source || engine.trackPath(current.index) || "";
|
|
852
|
+
if (!chosen) {
|
|
853
|
+
json(response, 422, { error: "nothing to broadcast" });
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const plan = options.broadcast?.() ?? { destinations: [], settings: DEFAULT_ENCODER };
|
|
857
|
+
const started = options.broadcaster.start({
|
|
858
|
+
source: chosen,
|
|
859
|
+
destinations: plan.destinations,
|
|
860
|
+
settings: plan.settings,
|
|
861
|
+
webAudio: false,
|
|
862
|
+
// Music has no picture, and RTMP platforms insist on a video track.
|
|
863
|
+
needsVideo: true,
|
|
864
|
+
});
|
|
865
|
+
if (!started.ok) {
|
|
866
|
+
json(response, 422, { error: started.error });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
json(response, 200, options.broadcaster.status());
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
// Names and URLs, never a key.
|
|
873
|
+
if (path === "/api/broadcast/destinations" && options.broadcast) {
|
|
874
|
+
json(response, 200, { destinations: options.broadcast().destinations.map(redact) });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
484
877
|
if (path === "/api/state") {
|
|
485
878
|
json(response, 200, engine.snapshot());
|
|
486
879
|
return;
|
|
@@ -789,13 +1182,78 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
789
1182
|
: new EmptyEngine(`No audio files under ${root}.`);
|
|
790
1183
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
791
1184
|
const key = options.key ? newKey() : null;
|
|
1185
|
+
// Minted whether or not it is published, so `nixamp admin` and the operator
|
|
1186
|
+
// both have a link they can hand out without handing over the controls.
|
|
1187
|
+
const listenKey = key === null ? null : newKey();
|
|
1188
|
+
// Configuration can arrive from the directory later, so it is a box the
|
|
1189
|
+
// paywall reads rather than a value it was handed once.
|
|
1190
|
+
let paywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
|
|
1191
|
+
const connections = new Connections();
|
|
1192
|
+
const paywall = createPaywall({
|
|
1193
|
+
config: () => paywallConfig,
|
|
1194
|
+
liveListeners: () => connections.listening,
|
|
1195
|
+
// The address a payer can actually reach: the public one where there is
|
|
1196
|
+
// one, since a quote pointing at 192.168.1.5 is one they cannot pay from.
|
|
1197
|
+
siteUrl: () => {
|
|
1198
|
+
const bound = server.address();
|
|
1199
|
+
const live = typeof bound === "object" && bound !== null ? bound.port : options.port;
|
|
1200
|
+
const reachable = reachableAddresses(options.host, live);
|
|
1201
|
+
return (reachable.find((a) => a.label === "on the internet") ?? reachable[0])?.url
|
|
1202
|
+
?? `http://127.0.0.1:${live}`;
|
|
1203
|
+
},
|
|
1204
|
+
// The operator drives with the control key, and is not a customer.
|
|
1205
|
+
exempt: (request) => key !== null && scopeOf(keyFrom(request, new URL(request.url ?? "/", "http://localhost")), key, null) === "control",
|
|
1206
|
+
});
|
|
1207
|
+
const channels = new Channels({
|
|
1208
|
+
ffmpeg: tools.ffmpeg,
|
|
1209
|
+
onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
1210
|
+
onEnd: (info) => console.log(` "${info.id}" stopped.`),
|
|
1211
|
+
});
|
|
1212
|
+
const destinations = parseDestinations(options.rtmp);
|
|
1213
|
+
const broadcaster = new Broadcaster(tools.ffmpeg);
|
|
1214
|
+
const ingest = options.ingest
|
|
1215
|
+
? new Ingest({
|
|
1216
|
+
ffmpeg: tools.ffmpeg,
|
|
1217
|
+
sink: "pipe:1",
|
|
1218
|
+
onStart: (session) => console.log(` ${session.name} started streaming in (${session.format}).`),
|
|
1219
|
+
onEnd: (session, error) => console.log(` ${session.name} stopped streaming in${error ? `: ${error}` : ""}.`),
|
|
1220
|
+
})
|
|
1221
|
+
: undefined;
|
|
1222
|
+
// The account signed in on this machine owns the server it starts. That is
|
|
1223
|
+
// the whole claim: `nixamp login` then `nixamp serve`, and the phone in your
|
|
1224
|
+
// pocket can administer it from anywhere by signing in as the same person.
|
|
1225
|
+
const session = readSession();
|
|
1226
|
+
const owner = new Owner({
|
|
1227
|
+
ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
|
|
1228
|
+
site: session?.site ?? DEFAULT_DIRECTORY,
|
|
1229
|
+
});
|
|
792
1230
|
const server = createServer(engine, {
|
|
793
1231
|
web,
|
|
794
1232
|
media: options.media,
|
|
1233
|
+
owner,
|
|
1234
|
+
channels,
|
|
1235
|
+
...(ingest ? { ingest } : {}),
|
|
1236
|
+
broadcaster,
|
|
1237
|
+
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
795
1238
|
version,
|
|
796
1239
|
key,
|
|
1240
|
+
listenKey,
|
|
1241
|
+
connections,
|
|
1242
|
+
paywall,
|
|
797
1243
|
ffmpeg: tools.ffmpeg,
|
|
798
1244
|
load: (next) => loadSource(tools, next),
|
|
1245
|
+
...(options.directory ? { directory: new Directory() } : {}),
|
|
1246
|
+
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
1247
|
+
// laptop has nobody to be an account of.
|
|
1248
|
+
...(options.directory && process.env["DATABASE_URL"]
|
|
1249
|
+
? {
|
|
1250
|
+
accounts: new Accounts({
|
|
1251
|
+
connectionString: process.env["DATABASE_URL"],
|
|
1252
|
+
secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
|
|
1253
|
+
}),
|
|
1254
|
+
secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
|
|
1255
|
+
}
|
|
1256
|
+
: {}),
|
|
799
1257
|
});
|
|
800
1258
|
// A port already in use is the most ordinary failure there is, and it
|
|
801
1259
|
// arrives as an unhandled 'error' event that takes the process down with a
|
|
@@ -838,6 +1296,15 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
838
1296
|
else {
|
|
839
1297
|
console.log(" Open that link once on a phone or a laptop and it stays signed in.");
|
|
840
1298
|
console.log(` Anything without the key gets a 401. Key: ${key}`);
|
|
1299
|
+
if (listenKey !== null) {
|
|
1300
|
+
console.log("");
|
|
1301
|
+
console.log(" A listen-only link, for someone you want to hear it but not drive it:");
|
|
1302
|
+
for (const { label, url } of addresses) {
|
|
1303
|
+
if (label === "here")
|
|
1304
|
+
continue;
|
|
1305
|
+
console.log(` ${shareLink(url, listenKey)}`);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
841
1308
|
}
|
|
842
1309
|
if (addresses.some((a) => a.label === "on the internet")) {
|
|
843
1310
|
console.log("");
|
|
@@ -847,6 +1314,31 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
847
1314
|
}
|
|
848
1315
|
if (!options.media)
|
|
849
1316
|
console.log(" Audio stays on this machine: --no-media is set.");
|
|
1317
|
+
if (owner.claimed) {
|
|
1318
|
+
console.log(` ${session?.email} can administer this from anywhere, signed in at ${session?.site}.`);
|
|
1319
|
+
}
|
|
1320
|
+
if (options.ingest)
|
|
1321
|
+
console.log(" Accepting a live stream in at POST /api/ingest.");
|
|
1322
|
+
let rtmp = null;
|
|
1323
|
+
if (options.rtmpIn > 0) {
|
|
1324
|
+
const publish = addresses.find((a) => a.label !== "here") ?? addresses[0];
|
|
1325
|
+
const host = publish ? new URL(publish.url).hostname : "127.0.0.1";
|
|
1326
|
+
// One listener per stream, because ffmpeg's RTMP listener serves a single
|
|
1327
|
+
// connection per process. Three devices going live at once is three ports.
|
|
1328
|
+
const slots = Array.from({ length: options.rtmpStreams }, (_, i) => ({
|
|
1329
|
+
port: options.rtmpIn + i,
|
|
1330
|
+
id: i === 0 ? "live" : `live-${i + 1}`,
|
|
1331
|
+
}));
|
|
1332
|
+
rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
|
|
1333
|
+
rtmp.listen(slots);
|
|
1334
|
+
console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
|
|
1335
|
+
for (const slot of slots) {
|
|
1336
|
+
console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (destinations.length > 0) {
|
|
1340
|
+
console.log(` Ready to broadcast to ${destinations.map((d) => d.name).join(", ")}.`);
|
|
1341
|
+
}
|
|
850
1342
|
if (web === null)
|
|
851
1343
|
console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
|
|
852
1344
|
// Listening on every interface proves the socket is open here and nothing
|
|
@@ -889,7 +1381,54 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
889
1381
|
}
|
|
890
1382
|
}
|
|
891
1383
|
}
|
|
1384
|
+
// The listing carries the listen link, and only ever a public address: an
|
|
1385
|
+
// entry pointing at 192.168.1.5 is one nobody outside that house can open.
|
|
1386
|
+
const publishable_ = addresses.find((a) => a.label === "on the internet")
|
|
1387
|
+
?? addresses.find((a) => a.label === "on tailscale");
|
|
1388
|
+
let publisher = null;
|
|
1389
|
+
if (options.publish !== "no" && publishable_) {
|
|
1390
|
+
const listen = shareLink(publishable_.url, listenKey);
|
|
1391
|
+
const wanted = options.publish === "yes"
|
|
1392
|
+
? true
|
|
1393
|
+
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
1394
|
+
if (wanted) {
|
|
1395
|
+
publisher = new Publisher({
|
|
1396
|
+
directory: DEFAULT_DIRECTORY,
|
|
1397
|
+
name: options.name || hostname(),
|
|
1398
|
+
url: listen,
|
|
1399
|
+
tracks: tracks.length,
|
|
1400
|
+
nowPlaying: () => {
|
|
1401
|
+
const snapshot = engine.snapshot();
|
|
1402
|
+
return snapshot.tracks[snapshot.index]?.title ?? "";
|
|
1403
|
+
},
|
|
1404
|
+
onConfig: (remote) => {
|
|
1405
|
+
const next = applyRemoteConfig(paywallConfig, remote?.x402);
|
|
1406
|
+
if (JSON.stringify(next) === JSON.stringify(paywallConfig))
|
|
1407
|
+
return;
|
|
1408
|
+
paywallConfig = next;
|
|
1409
|
+
console.log(next.enabled
|
|
1410
|
+
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
1411
|
+
: " nixamp.com turned paid listening off.");
|
|
1412
|
+
},
|
|
1413
|
+
});
|
|
1414
|
+
const listing = await publisher.start();
|
|
1415
|
+
console.log("");
|
|
1416
|
+
console.log(listing
|
|
1417
|
+
? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
|
|
1418
|
+
: ` Could not reach ${DEFAULT_DIRECTORY}; not listed.`);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
else if (options.publish === "yes" && !publishable_) {
|
|
1422
|
+
console.log("");
|
|
1423
|
+
console.log(" --publish needs an address the world can reach. This machine has none.");
|
|
1424
|
+
}
|
|
892
1425
|
const shutdown = () => {
|
|
1426
|
+
rtmp?.stop();
|
|
1427
|
+
channels.stopAll();
|
|
1428
|
+
ingest?.stopRtmp();
|
|
1429
|
+
ingest?.close();
|
|
1430
|
+
broadcaster.stop();
|
|
1431
|
+
void publisher?.stop();
|
|
893
1432
|
closePort?.();
|
|
894
1433
|
engine.stop();
|
|
895
1434
|
server.close(() => process.exit(0));
|
|
@@ -899,12 +1438,72 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
899
1438
|
process.on("SIGINT", shutdown);
|
|
900
1439
|
process.on("SIGTERM", shutdown);
|
|
901
1440
|
}
|
|
1441
|
+
/**
|
|
1442
|
+
* `--rtmp youtube=<key>` or `--rtmp name=rtmp://host/app/key`.
|
|
1443
|
+
*
|
|
1444
|
+
* A key is a password, so it is taken from the command line or the environment
|
|
1445
|
+
* and never from a request: a client that could name its own destination could
|
|
1446
|
+
* point your broadcast at itself.
|
|
1447
|
+
*/
|
|
1448
|
+
export function parseDestinations(specs) {
|
|
1449
|
+
const out = [];
|
|
1450
|
+
for (const [index, spec] of specs.entries()) {
|
|
1451
|
+
const at = spec.indexOf("=");
|
|
1452
|
+
if (at <= 0)
|
|
1453
|
+
continue;
|
|
1454
|
+
const name = spec.slice(0, at).trim();
|
|
1455
|
+
const rest = spec.slice(at + 1).trim();
|
|
1456
|
+
if (!name || !rest)
|
|
1457
|
+
continue;
|
|
1458
|
+
const preset = PRESETS[name.toLowerCase()];
|
|
1459
|
+
if (preset && !/^rtmps?:\/\//i.test(rest)) {
|
|
1460
|
+
out.push({ id: String(index + 1), name, url: preset, key: rest, enabled: true });
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (!/^rtmps?:\/\//i.test(rest))
|
|
1464
|
+
continue;
|
|
1465
|
+
// A full URL: the last path segment is the key.
|
|
1466
|
+
const cut = rest.lastIndexOf("/");
|
|
1467
|
+
if (cut <= "rtmp://".length)
|
|
1468
|
+
continue;
|
|
1469
|
+
out.push({
|
|
1470
|
+
id: String(index + 1),
|
|
1471
|
+
name,
|
|
1472
|
+
url: rest.slice(0, cut),
|
|
1473
|
+
key: rest.slice(cut + 1),
|
|
1474
|
+
enabled: true,
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
return out;
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Which account the signed-in session belongs to. Asked once at startup rather
|
|
1481
|
+
* than trusted from the file: a token that nixamp.com no longer accepts should
|
|
1482
|
+
* not confer ownership of anything.
|
|
1483
|
+
*/
|
|
1484
|
+
async function ownerIdOf(session) {
|
|
1485
|
+
try {
|
|
1486
|
+
const answer = await fetch(`${session.site}/api/v1/auth/me`, {
|
|
1487
|
+
headers: { authorization: `Bearer ${session.token}` },
|
|
1488
|
+
});
|
|
1489
|
+
if (!answer.ok)
|
|
1490
|
+
return "";
|
|
1491
|
+
const body = (await answer.json());
|
|
1492
|
+
return typeof body.account?.id === "string" ? body.account.id : "";
|
|
1493
|
+
}
|
|
1494
|
+
catch {
|
|
1495
|
+
// Offline at startup means no remote administration until a restart, and
|
|
1496
|
+
// the control key still works. Better than claiming an owner we cannot
|
|
1497
|
+
// check.
|
|
1498
|
+
return "";
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
902
1501
|
/** The built PWA, when it is sitting next to us in the same install. */
|
|
903
1502
|
function defaultWebDir() {
|
|
904
1503
|
const fromEnv = process.env.NIXAMP_WEB_DIR;
|
|
905
1504
|
if (fromEnv && isFile(join(fromEnv, "index.html")))
|
|
906
1505
|
return fromEnv;
|
|
907
|
-
const here = new URL(".", import.meta.url)
|
|
1506
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
908
1507
|
for (const guess of [
|
|
909
1508
|
join(here, "..", "web", "dist"),
|
|
910
1509
|
join(here, "..", "..", "web", "dist"),
|