nixamp 0.3.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/dist/directory.d.ts +126 -3
- package/dist/directory.js +171 -7
- 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/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/partyline.d.ts +259 -0
- package/dist/partyline.js +616 -0
- package/dist/paywall.js +1 -1
- package/dist/playlist.js +5 -0
- package/dist/publish.d.ts +21 -0
- package/dist/publish.js +18 -2
- package/dist/server.d.ts +15 -0
- package/dist/server.js +558 -11
- package/dist/share.d.ts +10 -0
- package/dist/share.js +12 -0
- package/package.json +5 -2
- package/src/directory.ts +232 -5
- package/src/durable.ts +215 -0
- package/src/follows.ts +307 -0
- package/src/notify.ts +217 -0
- package/src/optin.ts +128 -0
- package/src/partyline.ts +742 -0
- package/src/paywall.ts +1 -1
- package/src/playlist.ts +5 -0
- package/src/publish.ts +38 -2
- package/src/server.ts +610 -10
- package/src/share.ts +13 -0
- package/web/dist/assets/{index-0wAv50Ay.css → index-DSIDSSPF.css} +1 -1
- package/web/dist/assets/index-qRguFskX.js +1 -0
- package/web/dist/index.html +27 -2
- package/web/dist/install.sh +82 -0
- package/web/dist/sw.js +45 -3
- package/web/dist/assets/index-WYJ6R4uF.js +0 -1
package/dist/server.js
CHANGED
|
@@ -22,11 +22,17 @@ import { RtmpListeners } from "./rtmp-in.js";
|
|
|
22
22
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
|
|
23
23
|
import { needsAdmin, Owner } from "./owner.js";
|
|
24
24
|
import { readSession } from "./session.js";
|
|
25
|
-
import { Directory, parseAnnouncement } from "./directory.js";
|
|
25
|
+
import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
|
|
26
|
+
import { PartyLine, telnyxSms } from "./partyline.js";
|
|
27
|
+
import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
|
|
28
|
+
import pg from "pg";
|
|
29
|
+
import { Follows, phoneFrom } from "./follows.js";
|
|
30
|
+
import { Durable } from "./durable.js";
|
|
31
|
+
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
26
32
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
27
33
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
28
|
-
import { isRemote } from "./sources.js";
|
|
29
|
-
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, newKey, portCommands, reachableAddresses, scopeOf, shareLink, } from "./share.js";
|
|
34
|
+
import { isRemote, playsInBrowser } from "./sources.js";
|
|
35
|
+
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
30
36
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
31
37
|
import { fileURLToPath } from "node:url";
|
|
32
38
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
@@ -533,6 +539,202 @@ export function createHandler(engine, options) {
|
|
|
533
539
|
response.end();
|
|
534
540
|
return;
|
|
535
541
|
}
|
|
542
|
+
// The page explaining the reminder texts. Public for the same reason the
|
|
543
|
+
// webhook is: the reader is a carrier reviewing the number, or somebody
|
|
544
|
+
// who just got a message and wants it to stop. Neither has a share link.
|
|
545
|
+
if (path === OPT_IN_PATH && options.partyLine) {
|
|
546
|
+
const body = optInPage();
|
|
547
|
+
response.writeHead(200, {
|
|
548
|
+
...CORS,
|
|
549
|
+
"content-type": "text/html; charset=utf-8",
|
|
550
|
+
"content-length": Buffer.byteLength(body),
|
|
551
|
+
});
|
|
552
|
+
response.end(request.method === "HEAD" ? undefined : body);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// --- following a broadcaster ------------------------------------------
|
|
556
|
+
//
|
|
557
|
+
// Behind the sign-in rather than the share key: a follow belongs to an
|
|
558
|
+
// account, and an account is the only thing that makes "notify me on my
|
|
559
|
+
// other device" mean anything.
|
|
560
|
+
if (path.startsWith("/api/v1/follows") && options.follows && options.accounts) {
|
|
561
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
562
|
+
if (me === null) {
|
|
563
|
+
json(response, 401, { error: "sign in to follow" });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const follows = options.follows;
|
|
567
|
+
if (path === "/api/v1/follows" && request.method === "GET") {
|
|
568
|
+
const ids = await follows.following(me.id);
|
|
569
|
+
// An id is not a name. The directory is the only thing that knows what
|
|
570
|
+
// an account calls itself, from the last stream it announced -- which
|
|
571
|
+
// is empty for somebody who has never streamed, and the caller decides
|
|
572
|
+
// what to show for that rather than being handed a blank.
|
|
573
|
+
json(response, 200, {
|
|
574
|
+
following: ids.map((id) => ({
|
|
575
|
+
id,
|
|
576
|
+
name: options.directory?.nameOf(id) ?? "",
|
|
577
|
+
live: options.directory?.isLive(id) ?? false,
|
|
578
|
+
})),
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const streamer = path.slice("/api/v1/follows/".length);
|
|
583
|
+
if (!path.startsWith("/api/v1/follows/") || !streamer) {
|
|
584
|
+
json(response, 404, { error: "no such endpoint" });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
588
|
+
const added = await follows.follow(me.id, decodeURIComponent(streamer));
|
|
589
|
+
// Following yourself is refused rather than silently stored: you do
|
|
590
|
+
// not need telling that you went live.
|
|
591
|
+
json(response, added ? 200 : 422, added
|
|
592
|
+
? { following: true, followers: await follows.followerCount(decodeURIComponent(streamer)) }
|
|
593
|
+
: { error: "you cannot follow yourself" });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (request.method === "DELETE") {
|
|
597
|
+
await follows.unfollow(me.id, decodeURIComponent(streamer));
|
|
598
|
+
json(response, 200, { following: false });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (request.method === "GET") {
|
|
602
|
+
json(response, 200, { following: await follows.isFollowing(me.id, decodeURIComponent(streamer)) });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
json(response, 405, { error: "PUT, DELETE or GET" });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
// --- where to reach a follower ----------------------------------------
|
|
609
|
+
if (path.startsWith("/api/v1/notify") && options.follows && options.accounts) {
|
|
610
|
+
// The key is public by design: it is what a browser needs before it can
|
|
611
|
+
// ask permission, and it is useless without the private half.
|
|
612
|
+
if (path === "/api/v1/notify/key" && request.method === "GET") {
|
|
613
|
+
json(response, 200, { publicKey: options.vapidPublicKey ?? "" });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
617
|
+
if (me === null) {
|
|
618
|
+
json(response, 401, { error: "sign in first" });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const follows = options.follows;
|
|
622
|
+
if (path === "/api/v1/notify/prefs") {
|
|
623
|
+
if (request.method === "GET") {
|
|
624
|
+
json(response, 200, await follows.prefs(me.id));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (request.method !== "PUT" && request.method !== "POST") {
|
|
628
|
+
json(response, 405, { error: "GET or PUT" });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
let body;
|
|
632
|
+
try {
|
|
633
|
+
body = JSON.parse(await readBody(request));
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
json(response, 400, { error: "bad JSON" });
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// A number we cannot dial is worse than no number: it is a text that
|
|
640
|
+
// silently goes nowhere for as long as nobody checks.
|
|
641
|
+
if (body["phone"] !== undefined && body["phone"] !== "" && !phoneFrom(body["phone"])) {
|
|
642
|
+
json(response, 422, { error: "that does not look like a phone number" });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
await follows.setPrefs(me.id, {
|
|
646
|
+
...(body["phone"] === undefined ? {} : { phone: String(body["phone"]) }),
|
|
647
|
+
...(typeof body["wantsEmail"] === "boolean" ? { wantsEmail: body["wantsEmail"] } : {}),
|
|
648
|
+
...(typeof body["wantsSms"] === "boolean" ? { wantsSms: body["wantsSms"] } : {}),
|
|
649
|
+
...(typeof body["wantsWeb"] === "boolean" ? { wantsWeb: body["wantsWeb"] } : {}),
|
|
650
|
+
});
|
|
651
|
+
json(response, 200, await follows.prefs(me.id));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (path === "/api/v1/notify/subscribe") {
|
|
655
|
+
if (request.method === "DELETE") {
|
|
656
|
+
const endpoint = url.searchParams.get("endpoint") ?? "";
|
|
657
|
+
await follows.removePush(endpoint);
|
|
658
|
+
json(response, 200, { ok: true });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (request.method !== "POST" && request.method !== "PUT") {
|
|
662
|
+
json(response, 405, { error: "POST or DELETE" });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
let body;
|
|
666
|
+
try {
|
|
667
|
+
body = JSON.parse(await readBody(request));
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
json(response, 400, { error: "bad JSON" });
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const endpoint = typeof body.endpoint === "string" ? body.endpoint : "";
|
|
674
|
+
const p256dh = typeof body.keys?.p256dh === "string" ? body.keys.p256dh : "";
|
|
675
|
+
const auth = typeof body.keys?.auth === "string" ? body.keys.auth : "";
|
|
676
|
+
if (!endpoint || !p256dh || !auth) {
|
|
677
|
+
json(response, 422, { error: "a subscription needs an endpoint and both keys" });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
await follows.addPush(me.id, { endpoint, p256dh, auth });
|
|
681
|
+
json(response, 200, { ok: true });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
json(response, 404, { error: "no such endpoint" });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
// --- the party line ---------------------------------------------------
|
|
688
|
+
//
|
|
689
|
+
// Ahead of the share-key check because the caller is a telephone. Telnyx
|
|
690
|
+
// has no cookie and no link; what it has is an ed25519 signature over the
|
|
691
|
+
// body, which is a stronger claim than a key in a URL anyway.
|
|
692
|
+
if (path.startsWith("/api/v1/partyline/") && options.partyLine) {
|
|
693
|
+
const partyLine = options.partyLine;
|
|
694
|
+
if (path === "/api/v1/partyline/rooms") {
|
|
695
|
+
json(response, 200, { rooms: partyLine.list() });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (path !== "/api/v1/partyline/webhook") {
|
|
699
|
+
json(response, 404, { error: "no such endpoint" });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (request.method !== "POST") {
|
|
703
|
+
json(response, 405, { error: "POST only" });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
// The bytes as they arrived. Parsing first and reserialising would
|
|
707
|
+
// change the whitespace the signature was computed over.
|
|
708
|
+
let raw;
|
|
709
|
+
try {
|
|
710
|
+
raw = await readBody(request);
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
json(response, 413, { error: "body too large" });
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const signature = request.headers["telnyx-signature-ed25519"];
|
|
717
|
+
const timestamp = request.headers["telnyx-timestamp"];
|
|
718
|
+
const ok = partyLine.verify(raw, typeof signature === "string" ? signature : undefined, typeof timestamp === "string" ? timestamp : undefined);
|
|
719
|
+
if (!ok) {
|
|
720
|
+
json(response, 401, { error: "bad signature" });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
let event;
|
|
724
|
+
try {
|
|
725
|
+
event = JSON.parse(raw);
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
json(response, 400, { error: "bad JSON" });
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
// Answer first, act second. Telnyx retries anything it does not hear
|
|
732
|
+
// back about quickly, and a retried call.answered would ask the caller
|
|
733
|
+
// which room they wanted twice.
|
|
734
|
+
json(response, 200, { ok: true });
|
|
735
|
+
void partyLine.handle(event.data ?? {}).catch(() => { });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
536
738
|
// /api/health answers unauthenticated on purpose: it is how you check the
|
|
537
739
|
// port is open from another device before wondering whether the link is
|
|
538
740
|
// wrong, and it says nothing about the library.
|
|
@@ -615,16 +817,53 @@ export function createHandler(engine, options) {
|
|
|
615
817
|
response.end(JSON.stringify({ account: result.account, token: result.token }));
|
|
616
818
|
return;
|
|
617
819
|
}
|
|
618
|
-
// The directory is public
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
820
|
+
// The directory is public to read and answered before the key check,
|
|
821
|
+
// because a visitor to nixamp.com has no key and is exactly who it is for.
|
|
822
|
+
//
|
|
823
|
+
// Announcing is not public any more. A listing now carries a phone number
|
|
824
|
+
// people dial and minutes we pay for, so it has to be attributable to
|
|
825
|
+
// somebody: broadcasters register, and a caller just dials. Reading stays
|
|
826
|
+
// open to everyone -- the whole point is a directory a stranger can browse.
|
|
622
827
|
if (path === "/api/directory" && options.directory) {
|
|
623
828
|
if (request.method === "GET") {
|
|
624
|
-
|
|
829
|
+
// Each stream carries its call-in code and how many people are on the
|
|
830
|
+
// phone for it. The code is published on purpose: it is a public
|
|
831
|
+
// call-in line, and a listing you cannot dial is a listing of nothing.
|
|
832
|
+
const onThePhone = options.partyLine;
|
|
833
|
+
const streams = options.directory.list().map((stream) => ({
|
|
834
|
+
...stream,
|
|
835
|
+
callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
|
|
836
|
+
}));
|
|
837
|
+
// Recently ended too, because following exists to hear about
|
|
838
|
+
// broadcasts you would otherwise miss -- and a list of only what is on
|
|
839
|
+
// can only be used to follow somebody during a broadcast you did not
|
|
840
|
+
// miss. No url and no code: there is nothing to listen to.
|
|
841
|
+
const recent = options.directory.recentlyEnded().map((stream) => ({
|
|
842
|
+
name: stream.name,
|
|
843
|
+
ownerId: stream.ownerId,
|
|
844
|
+
nowPlaying: stream.nowPlaying,
|
|
845
|
+
endedAt: stream.endedAt,
|
|
846
|
+
}));
|
|
847
|
+
json(response, 200, { streams, recent, callIn: CALL_IN_NUMBER, now: Date.now() });
|
|
625
848
|
return;
|
|
626
849
|
}
|
|
627
850
|
if (request.method === "POST") {
|
|
851
|
+
// Only where there are accounts to check against. An instance with no
|
|
852
|
+
// Accounts is somebody's laptop, which has no registration to demand.
|
|
853
|
+
let ownerId = "";
|
|
854
|
+
if (options.accounts) {
|
|
855
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
856
|
+
if (who === null) {
|
|
857
|
+
json(response, 401, {
|
|
858
|
+
error: "sign in to list a stream: nixamp login, then nixamp serve --directory",
|
|
859
|
+
});
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
// From the token, never the body. A stream that could name its own
|
|
863
|
+
// owner could name somebody else's, and their followers would be
|
|
864
|
+
// told about a broadcast that person is not making.
|
|
865
|
+
ownerId = who.id;
|
|
866
|
+
}
|
|
628
867
|
let announcement;
|
|
629
868
|
try {
|
|
630
869
|
announcement = parseAnnouncement(JSON.parse(await readBody(request)));
|
|
@@ -637,7 +876,16 @@ export function createHandler(engine, options) {
|
|
|
637
876
|
json(response, 422, { error: "a listing needs a name and a URL a browser can reach" });
|
|
638
877
|
return;
|
|
639
878
|
}
|
|
640
|
-
|
|
879
|
+
const listing = options.directory.announce(announcement, ownerId);
|
|
880
|
+
// A stream reappearing is the event somebody asked to be told about.
|
|
881
|
+
// Answered first and texted after, because the publisher's heartbeat
|
|
882
|
+
// should not wait on an SMS gateway.
|
|
883
|
+
json(response, 200, listing);
|
|
884
|
+
if (options.partyLine) {
|
|
885
|
+
void options.partyLine
|
|
886
|
+
.wentLive({ code: listing.code, name: listing.name, nowPlaying: listing.nowPlaying })
|
|
887
|
+
.catch(() => { });
|
|
888
|
+
}
|
|
641
889
|
return;
|
|
642
890
|
}
|
|
643
891
|
if (request.method === "DELETE") {
|
|
@@ -981,13 +1229,36 @@ export function createHandler(engine, options) {
|
|
|
981
1229
|
return;
|
|
982
1230
|
}
|
|
983
1231
|
watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
|
|
984
|
-
|
|
1232
|
+
// A browser asks for every track here, and a matroska or an avi handed
|
|
1233
|
+
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
1234
|
+
// and transcoding gives it up, but an unseekable film beats a silent
|
|
1235
|
+
// one -- and the seekable formats are untouched.
|
|
1236
|
+
if (playsInBrowser(file))
|
|
1237
|
+
sendFile(request, response, file);
|
|
1238
|
+
else
|
|
1239
|
+
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
985
1240
|
return;
|
|
986
1241
|
}
|
|
987
1242
|
// Whatever the source is, this comes back as MP3 a browser will play:
|
|
988
1243
|
// a flac, a wma, a URL, an HLS stream. ffmpeg reads them all and we hand
|
|
989
1244
|
// the bytes on as they arrive, so a live stream starts immediately rather
|
|
990
1245
|
// than after it ends, which for a live stream is never.
|
|
1246
|
+
// One address that keeps playing, for a listener that cannot ask for the
|
|
1247
|
+
// next track: the phone line hands exactly this to Telnyx.
|
|
1248
|
+
if (path === "/api/live") {
|
|
1249
|
+
if (!options.media) {
|
|
1250
|
+
json(response, 403, { error: "media streaming is off" });
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
const current = engine.snapshot();
|
|
1254
|
+
if (current.tracks.length === 0) {
|
|
1255
|
+
json(response, 404, { error: "nothing is playing" });
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
watch(request, response, "stream", current.tracks[current.index]?.title ?? "live");
|
|
1259
|
+
liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
991
1262
|
if (path.startsWith("/api/stream/")) {
|
|
992
1263
|
const index = Number(path.slice("/api/stream/".length));
|
|
993
1264
|
const source = Number.isInteger(index) ? engine.trackPath(index) : undefined;
|
|
@@ -1038,6 +1309,127 @@ function readIfPossible(path) {
|
|
|
1038
1309
|
return null;
|
|
1039
1310
|
}
|
|
1040
1311
|
}
|
|
1312
|
+
/** How long to wait before looking again when the player has not moved on. */
|
|
1313
|
+
const LIVE_GAP_MS = 500;
|
|
1314
|
+
/** How long to wait when there is nothing to play at all yet. */
|
|
1315
|
+
const LIVE_IDLE_MS = 2000;
|
|
1316
|
+
/**
|
|
1317
|
+
* Whatever is playing, as one endless MP3.
|
|
1318
|
+
*
|
|
1319
|
+
* /api/stream/N is one track: it needs an index, and it stops at the end of
|
|
1320
|
+
* the song. That is right for a browser, which knows what is playing and can
|
|
1321
|
+
* ask for the next one. It is wrong for everything that cannot -- a telephone
|
|
1322
|
+
* call, `curl | mpv`, anything handed a single address and expected to keep
|
|
1323
|
+
* hearing sound. Those need one URL that never ends and never needs asking
|
|
1324
|
+
* again, which is what a listener means by "the stream".
|
|
1325
|
+
*
|
|
1326
|
+
* So this follows the player rather than an index: transcode what is playing,
|
|
1327
|
+
* and when that track ends look at what is playing now and keep writing into
|
|
1328
|
+
* the same response. The listener sees one continuous audio/mpeg body.
|
|
1329
|
+
*
|
|
1330
|
+
* Read at native rate (-re), unlike /api/stream/N which is free to run ahead
|
|
1331
|
+
* into a browser's buffer. Here running ahead would finish the song in two
|
|
1332
|
+
* seconds and then sit waiting for the player to catch up, so the thing that
|
|
1333
|
+
* decides what plays next would be minutes behind what the listener hears.
|
|
1334
|
+
*/
|
|
1335
|
+
function liveAudio(request, response, engine, ffmpeg) {
|
|
1336
|
+
const [command, ...prefix] = ffmpeg;
|
|
1337
|
+
let child = null;
|
|
1338
|
+
let waiting = null;
|
|
1339
|
+
let closed = false;
|
|
1340
|
+
let started = false;
|
|
1341
|
+
let playing = -1;
|
|
1342
|
+
// Held back until the first byte, for the reason transcode() holds it back:
|
|
1343
|
+
// a 200 with nothing behind it is indistinguishable from silence.
|
|
1344
|
+
const begin = () => {
|
|
1345
|
+
if (started || closed)
|
|
1346
|
+
return;
|
|
1347
|
+
started = true;
|
|
1348
|
+
response.writeHead(200, {
|
|
1349
|
+
...CORS,
|
|
1350
|
+
"content-type": "audio/mpeg",
|
|
1351
|
+
"cache-control": "no-store",
|
|
1352
|
+
"transfer-encoding": "chunked",
|
|
1353
|
+
});
|
|
1354
|
+
};
|
|
1355
|
+
const later = (ms, run) => {
|
|
1356
|
+
if (waiting)
|
|
1357
|
+
clearTimeout(waiting);
|
|
1358
|
+
waiting = setTimeout(run, ms);
|
|
1359
|
+
waiting.unref?.();
|
|
1360
|
+
};
|
|
1361
|
+
const stop = () => {
|
|
1362
|
+
if (closed)
|
|
1363
|
+
return;
|
|
1364
|
+
closed = true;
|
|
1365
|
+
if (waiting)
|
|
1366
|
+
clearTimeout(waiting);
|
|
1367
|
+
waiting = null;
|
|
1368
|
+
unsubscribe();
|
|
1369
|
+
child?.kill("SIGKILL");
|
|
1370
|
+
child = null;
|
|
1371
|
+
if (!response.writableEnded)
|
|
1372
|
+
response.end();
|
|
1373
|
+
};
|
|
1374
|
+
const next = () => {
|
|
1375
|
+
if (closed || child !== null)
|
|
1376
|
+
return;
|
|
1377
|
+
if (waiting) {
|
|
1378
|
+
clearTimeout(waiting);
|
|
1379
|
+
waiting = null;
|
|
1380
|
+
}
|
|
1381
|
+
const snapshot = engine.snapshot();
|
|
1382
|
+
const source = engine.trackPath(snapshot.index);
|
|
1383
|
+
if (source === undefined) {
|
|
1384
|
+
// A playlist that was replaced out from under us, or one that is empty
|
|
1385
|
+
// for the moment. Keep the connection and keep looking.
|
|
1386
|
+
later(LIVE_IDLE_MS, next);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
playing = snapshot.index;
|
|
1390
|
+
const spawned = spawn(command, [
|
|
1391
|
+
...prefix,
|
|
1392
|
+
"-hide_banner",
|
|
1393
|
+
"-loglevel", "error",
|
|
1394
|
+
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
1395
|
+
"-re",
|
|
1396
|
+
"-i", source,
|
|
1397
|
+
"-vn",
|
|
1398
|
+
"-f", "mp3",
|
|
1399
|
+
"-b:a", "192k",
|
|
1400
|
+
"-",
|
|
1401
|
+
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1402
|
+
child = spawned;
|
|
1403
|
+
spawned.stdout.once("data", begin);
|
|
1404
|
+
spawned.stdout.on("error", () => spawned.kill("SIGKILL"));
|
|
1405
|
+
// end: false, because the response outlives this track. Ending it here is
|
|
1406
|
+
// exactly the bug this endpoint exists to avoid.
|
|
1407
|
+
spawned.stdout.pipe(response, { end: false });
|
|
1408
|
+
spawned.stderr.resume();
|
|
1409
|
+
spawned.on("error", stop);
|
|
1410
|
+
spawned.on("close", () => {
|
|
1411
|
+
if (child !== spawned)
|
|
1412
|
+
return;
|
|
1413
|
+
child = null;
|
|
1414
|
+
if (closed)
|
|
1415
|
+
return;
|
|
1416
|
+
// Follow the player if it has already moved on. If it has not, look
|
|
1417
|
+
// again shortly -- which is also what makes a single-track library
|
|
1418
|
+
// repeat rather than fall silent.
|
|
1419
|
+
later(engine.snapshot().index === playing ? LIVE_GAP_MS : 0, next);
|
|
1420
|
+
});
|
|
1421
|
+
};
|
|
1422
|
+
// A track change that lands while we are between songs is the signal to go
|
|
1423
|
+
// now rather than wait out the poll.
|
|
1424
|
+
const unsubscribe = engine.subscribe(() => {
|
|
1425
|
+
if (child === null && !closed && engine.snapshot().index !== playing)
|
|
1426
|
+
next();
|
|
1427
|
+
});
|
|
1428
|
+
response.on("close", stop);
|
|
1429
|
+
response.on("error", stop);
|
|
1430
|
+
request.on("close", stop);
|
|
1431
|
+
next();
|
|
1432
|
+
}
|
|
1041
1433
|
/**
|
|
1042
1434
|
* Decode anything and hand back MP3, as it is produced.
|
|
1043
1435
|
*
|
|
@@ -1222,11 +1614,150 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
1222
1614
|
// The account signed in on this machine owns the server it starts. That is
|
|
1223
1615
|
// the whole claim: `nixamp login` then `nixamp serve`, and the phone in your
|
|
1224
1616
|
// pocket can administer it from anywhere by signing in as the same person.
|
|
1617
|
+
// Following outlives every stream, so unlike the rest of this it wants a
|
|
1618
|
+
// database. Only where there is one: a nixamp on a laptop has no followers.
|
|
1619
|
+
const pool = options.directory && process.env["DATABASE_URL"]
|
|
1620
|
+
? new pg.Pool({ connectionString: process.env["DATABASE_URL"] })
|
|
1621
|
+
: undefined;
|
|
1622
|
+
const follows = pool ? new Follows(pool) : undefined;
|
|
1623
|
+
// The two things that were promises kept only in memory: a caller who was
|
|
1624
|
+
// told they would be texted, and the ended stream a code still points at.
|
|
1625
|
+
const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
|
|
1626
|
+
const vapidPublicKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
|
1627
|
+
const vapidPrivateKey = process.env["VAPID_PRIVATE_KEY"] ?? "";
|
|
1628
|
+
/**
|
|
1629
|
+
* Tell a broadcaster's followers, on whatever they asked to be told on.
|
|
1630
|
+
*
|
|
1631
|
+
* Fired from the directory on the transition to live rather than on every
|
|
1632
|
+
* heartbeat, and awaited by nobody: a publisher's heartbeat should not sit
|
|
1633
|
+
* waiting on a push service.
|
|
1634
|
+
*/
|
|
1635
|
+
const tellFollowers = (listing) => {
|
|
1636
|
+
if (follows === undefined || !listing.ownerId)
|
|
1637
|
+
return;
|
|
1638
|
+
const what = listing.nowPlaying ? ` Playing ${listing.nowPlaying}.` : "";
|
|
1639
|
+
const note = {
|
|
1640
|
+
title: `${listing.name} is live`,
|
|
1641
|
+
body: `${what} Listen at ${DEFAULT_DIRECTORY}/directory, or call ${CALL_IN_NUMBER} and key ${listing.code}.`.trim(),
|
|
1642
|
+
url: listing.url,
|
|
1643
|
+
};
|
|
1644
|
+
void follows
|
|
1645
|
+
.audience(listing.ownerId)
|
|
1646
|
+
.then((audience) => notifyAll(audience, note, {
|
|
1647
|
+
...(process.env["RESEND_API_KEY"]
|
|
1648
|
+
? {
|
|
1649
|
+
email: resendEmail({
|
|
1650
|
+
apiKey: process.env["RESEND_API_KEY"],
|
|
1651
|
+
from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
|
|
1652
|
+
onEvent: (message) => console.log(message),
|
|
1653
|
+
}),
|
|
1654
|
+
}
|
|
1655
|
+
: {}),
|
|
1656
|
+
...(process.env["TELNYX_API_KEY"] && process.env["PARTYLINE_SMS_FROM"]
|
|
1657
|
+
? {
|
|
1658
|
+
sms: telnyxSms({
|
|
1659
|
+
apiKey: process.env["TELNYX_API_KEY"],
|
|
1660
|
+
from: process.env["PARTYLINE_SMS_FROM"],
|
|
1661
|
+
onEvent: (message) => console.log(message),
|
|
1662
|
+
}),
|
|
1663
|
+
}
|
|
1664
|
+
: {}),
|
|
1665
|
+
...(vapidPublicKey && vapidPrivateKey
|
|
1666
|
+
? {
|
|
1667
|
+
push: webPush({
|
|
1668
|
+
publicKey: vapidPublicKey,
|
|
1669
|
+
privateKey: vapidPrivateKey,
|
|
1670
|
+
subject: process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY,
|
|
1671
|
+
onEvent: (message) => console.log(message),
|
|
1672
|
+
}),
|
|
1673
|
+
}
|
|
1674
|
+
: {}),
|
|
1675
|
+
// A subscription the vendor has retired is a row to delete, not a
|
|
1676
|
+
// failure to retry.
|
|
1677
|
+
onGone: (endpoint) => follows.removePush(endpoint),
|
|
1678
|
+
onEvent: (message) => console.log(message),
|
|
1679
|
+
}))
|
|
1680
|
+
.catch(() => { });
|
|
1681
|
+
};
|
|
1682
|
+
// Hoisted rather than built inline, because the party line needs the same
|
|
1683
|
+
// instance: a second Directory would be a second set of stream codes, and
|
|
1684
|
+
// the one the phone looked in would never be the one the publishers reach.
|
|
1685
|
+
const directory = options.directory
|
|
1686
|
+
? new Directory(undefined, undefined, undefined, tellFollowers)
|
|
1687
|
+
: undefined;
|
|
1688
|
+
if (directory && durable) {
|
|
1689
|
+
// Echoed rather than awaited: the directory answers from memory, so a
|
|
1690
|
+
// database that is briefly unreachable should cost the durability and not
|
|
1691
|
+
// the request.
|
|
1692
|
+
directory.persistTo({
|
|
1693
|
+
save: (item) => void durable.saveEnded(item),
|
|
1694
|
+
drop: (id) => void durable.dropEnded(id),
|
|
1695
|
+
});
|
|
1696
|
+
// And put back what the last process knew, without holding up the listen.
|
|
1697
|
+
void durable
|
|
1698
|
+
.loadEnded(Date.now() - ENDED_TTL_MS)
|
|
1699
|
+
.then((items) => {
|
|
1700
|
+
if (items.length > 0)
|
|
1701
|
+
console.log(` remembered ${items.length} stream(s) that had ended.`);
|
|
1702
|
+
directory.seedEnded(items);
|
|
1703
|
+
})
|
|
1704
|
+
.catch(() => { });
|
|
1705
|
+
void durable.sweep(Date.now() - ENDED_TTL_MS, new Date(Date.now() - 30 * 24 * 60 * 60 * 1000));
|
|
1706
|
+
}
|
|
1225
1707
|
const session = readSession();
|
|
1226
1708
|
const owner = new Owner({
|
|
1227
1709
|
ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
|
|
1228
1710
|
site: session?.site ?? DEFAULT_DIRECTORY,
|
|
1229
1711
|
});
|
|
1712
|
+
// The party line answers a phone number, and there is only one number. Both
|
|
1713
|
+
// keys or neither: without the public key every webhook would be refused,
|
|
1714
|
+
// which is a worse failure than not offering the endpoint.
|
|
1715
|
+
const partyLine = options.directory && process.env["TELNYX_API_KEY"] && process.env["TELNYX_PUBLIC_KEY"]
|
|
1716
|
+
? new PartyLine({
|
|
1717
|
+
apiKey: process.env["TELNYX_API_KEY"],
|
|
1718
|
+
publicKey: process.env["TELNYX_PUBLIC_KEY"],
|
|
1719
|
+
streams: directory,
|
|
1720
|
+
callIn: CALL_IN_NUMBER,
|
|
1721
|
+
// Only when a sending number is configured. Without one the line
|
|
1722
|
+
// still answers and still says when the stream ended; it just does
|
|
1723
|
+
// not offer a text it could not send.
|
|
1724
|
+
...(process.env["PARTYLINE_SMS_FROM"]
|
|
1725
|
+
? {
|
|
1726
|
+
sms: telnyxSms({
|
|
1727
|
+
apiKey: process.env["TELNYX_API_KEY"],
|
|
1728
|
+
from: process.env["PARTYLINE_SMS_FROM"],
|
|
1729
|
+
onEvent: (message) => console.log(message),
|
|
1730
|
+
}),
|
|
1731
|
+
}
|
|
1732
|
+
: {}),
|
|
1733
|
+
...(process.env["PARTYLINE_GREETING"] ? { greeting: process.env["PARTYLINE_GREETING"] } : {}),
|
|
1734
|
+
...(process.env["PARTYLINE_VOICE"] ? { voice: process.env["PARTYLINE_VOICE"] } : {}),
|
|
1735
|
+
onEvent: (message) => console.log(message),
|
|
1736
|
+
})
|
|
1737
|
+
: undefined;
|
|
1738
|
+
if (partyLine && durable) {
|
|
1739
|
+
// Put back everybody a previous process promised to text, then keep
|
|
1740
|
+
// echoing. Seeding first means a stream that goes live during startup
|
|
1741
|
+
// still finds them.
|
|
1742
|
+
void durable
|
|
1743
|
+
.loadReminders()
|
|
1744
|
+
.then((waiting) => {
|
|
1745
|
+
const owed = [...waiting.values()].reduce((n, set) => n + set.size, 0);
|
|
1746
|
+
if (owed > 0)
|
|
1747
|
+
console.log(` ${owed} caller(s) are still owed a text.`);
|
|
1748
|
+
partyLine.persistRemindersTo({
|
|
1749
|
+
add: (code, phone) => void durable.addReminder(code, phone),
|
|
1750
|
+
take: (code) => durable.takeReminders(code),
|
|
1751
|
+
}, waiting);
|
|
1752
|
+
})
|
|
1753
|
+
.catch(() => {
|
|
1754
|
+
// Still worth echoing new ones even if the old list could not be read.
|
|
1755
|
+
partyLine.persistRemindersTo({
|
|
1756
|
+
add: (code, phone) => void durable.addReminder(code, phone),
|
|
1757
|
+
take: (code) => durable.takeReminders(code),
|
|
1758
|
+
});
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1230
1761
|
const server = createServer(engine, {
|
|
1231
1762
|
web,
|
|
1232
1763
|
media: options.media,
|
|
@@ -1242,7 +1773,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
1242
1773
|
paywall,
|
|
1243
1774
|
ffmpeg: tools.ffmpeg,
|
|
1244
1775
|
load: (next) => loadSource(tools, next),
|
|
1245
|
-
...(
|
|
1776
|
+
...(directory ? { directory } : {}),
|
|
1777
|
+
...(follows ? { follows, vapidPublicKey } : {}),
|
|
1778
|
+
...(partyLine ? { partyLine } : {}),
|
|
1246
1779
|
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
1247
1780
|
// laptop has nobody to be an account of.
|
|
1248
1781
|
...(options.directory && process.env["DATABASE_URL"]
|
|
@@ -1388,6 +1921,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
1388
1921
|
let publisher = null;
|
|
1389
1922
|
if (options.publish !== "no" && publishable_) {
|
|
1390
1923
|
const listen = shareLink(publishable_.url, listenKey);
|
|
1924
|
+
// Announced next to the listen link, not instead of it: one is for a person
|
|
1925
|
+
// with a browser, the other for the phone line and anything else that is
|
|
1926
|
+
// handed one address and expected to play it.
|
|
1927
|
+
const audio = audioLink(publishable_.url, listenKey);
|
|
1391
1928
|
const wanted = options.publish === "yes"
|
|
1392
1929
|
? true
|
|
1393
1930
|
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
@@ -1396,7 +1933,17 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
1396
1933
|
directory: DEFAULT_DIRECTORY,
|
|
1397
1934
|
name: options.name || hostname(),
|
|
1398
1935
|
url: listen,
|
|
1936
|
+
audio,
|
|
1399
1937
|
tracks: tracks.length,
|
|
1938
|
+
// From `nixamp login`. The directory will not list a stream it cannot
|
|
1939
|
+
// attribute to somebody, because a listing is now a phone code that
|
|
1940
|
+
// costs money to answer.
|
|
1941
|
+
...(session?.token ? { token: session.token } : {}),
|
|
1942
|
+
onRefused: () => {
|
|
1943
|
+
console.log("");
|
|
1944
|
+
console.log(" nixamp.com would not list this stream: it needs an account.");
|
|
1945
|
+
console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
|
|
1946
|
+
},
|
|
1400
1947
|
nowPlaying: () => {
|
|
1401
1948
|
const snapshot = engine.snapshot();
|
|
1402
1949
|
return snapshot.tracks[snapshot.index]?.title ?? "";
|
package/dist/share.d.ts
CHANGED
|
@@ -37,6 +37,16 @@ export declare function reachableAddresses(host: string, port: number): {
|
|
|
37
37
|
}[];
|
|
38
38
|
/** The full link, key and all. */
|
|
39
39
|
export declare function shareLink(base: string, key: string | null): string;
|
|
40
|
+
/**
|
|
41
|
+
* The same stream, as bytes rather than as a page.
|
|
42
|
+
*
|
|
43
|
+
* A share link is for a browser: it answers 302, leaves a cookie behind and
|
|
44
|
+
* redirects to the player. Anything that cannot hold a cookie -- the phone
|
|
45
|
+
* line, curl, ffplay -- gets a 401 from it and no audio. This carries the key
|
|
46
|
+
* in the query instead, which keyFrom() accepts, so a single anonymous GET is
|
|
47
|
+
* enough to start hearing sound.
|
|
48
|
+
*/
|
|
49
|
+
export declare function audioLink(base: string, key: string | null): string;
|
|
40
50
|
/**
|
|
41
51
|
* What a key is allowed to do. An unknown key is allowed nothing, which is the
|
|
42
52
|
* same answer as no key at all.
|