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/src/server.ts
CHANGED
|
@@ -29,7 +29,13 @@ import { RtmpListeners } from "./rtmp-in.ts";
|
|
|
29
29
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
|
|
30
30
|
import { needsAdmin, Owner } from "./owner.ts";
|
|
31
31
|
import { readSession } from "./session.ts";
|
|
32
|
-
import { Directory, parseAnnouncement } from "./directory.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";
|
|
33
39
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
34
40
|
import {
|
|
35
41
|
applyRemoteConfig,
|
|
@@ -38,7 +44,7 @@ import {
|
|
|
38
44
|
type PaywallConfig,
|
|
39
45
|
paywallFromEnv,
|
|
40
46
|
} from "./paywall.ts";
|
|
41
|
-
import { isRemote } from "./sources.ts";
|
|
47
|
+
import { isRemote, playsInBrowser } from "./sources.ts";
|
|
42
48
|
import {
|
|
43
49
|
allowedForListening,
|
|
44
50
|
elevate,
|
|
@@ -51,6 +57,7 @@ import {
|
|
|
51
57
|
reachableAddresses,
|
|
52
58
|
scopeOf,
|
|
53
59
|
shareLink,
|
|
60
|
+
audioLink,
|
|
54
61
|
} from "./share.ts";
|
|
55
62
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
56
63
|
import { fileURLToPath } from "node:url";
|
|
@@ -621,6 +628,19 @@ export interface HandlerOptions {
|
|
|
621
628
|
secureCookies?: boolean;
|
|
622
629
|
/** Who may administer this server. */
|
|
623
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;
|
|
624
644
|
}
|
|
625
645
|
|
|
626
646
|
/**
|
|
@@ -678,6 +698,219 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
678
698
|
return;
|
|
679
699
|
}
|
|
680
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
|
+
|
|
681
914
|
// /api/health answers unauthenticated on purpose: it is how you check the
|
|
682
915
|
// port is open from another device before wondering whether the link is
|
|
683
916
|
// wrong, and it says nothing about the library.
|
|
@@ -771,16 +1004,54 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
771
1004
|
return;
|
|
772
1005
|
}
|
|
773
1006
|
|
|
774
|
-
// The directory is public
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
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.
|
|
778
1014
|
if (path === "/api/directory" && options.directory) {
|
|
779
1015
|
if (request.method === "GET") {
|
|
780
|
-
|
|
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() });
|
|
781
1035
|
return;
|
|
782
1036
|
}
|
|
783
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
|
+
|
|
784
1055
|
let announcement;
|
|
785
1056
|
try {
|
|
786
1057
|
announcement = parseAnnouncement(JSON.parse(await readBody(request)));
|
|
@@ -792,7 +1063,16 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
792
1063
|
json(response, 422, { error: "a listing needs a name and a URL a browser can reach" });
|
|
793
1064
|
return;
|
|
794
1065
|
}
|
|
795
|
-
|
|
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
|
+
}
|
|
796
1076
|
return;
|
|
797
1077
|
}
|
|
798
1078
|
if (request.method === "DELETE") {
|
|
@@ -1153,7 +1433,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1153
1433
|
return;
|
|
1154
1434
|
}
|
|
1155
1435
|
watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
|
|
1156
|
-
|
|
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"]);
|
|
1157
1442
|
return;
|
|
1158
1443
|
}
|
|
1159
1444
|
|
|
@@ -1161,6 +1446,23 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1161
1446
|
// a flac, a wma, a URL, an HLS stream. ffmpeg reads them all and we hand
|
|
1162
1447
|
// the bytes on as they arrive, so a live stream starts immediately rather
|
|
1163
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
|
+
|
|
1164
1466
|
if (path.startsWith("/api/stream/")) {
|
|
1165
1467
|
const index = Number(path.slice("/api/stream/".length));
|
|
1166
1468
|
const source = Number.isInteger(index) ? engine.trackPath(index) : undefined;
|
|
@@ -1212,6 +1514,138 @@ function readIfPossible(path: string): string | null {
|
|
|
1212
1514
|
}
|
|
1213
1515
|
}
|
|
1214
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
|
+
|
|
1215
1649
|
/**
|
|
1216
1650
|
* Decode anything and hand back MP3, as it is produced.
|
|
1217
1651
|
*
|
|
@@ -1413,12 +1847,162 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
1413
1847
|
// The account signed in on this machine owns the server it starts. That is
|
|
1414
1848
|
// the whole claim: `nixamp login` then `nixamp serve`, and the phone in your
|
|
1415
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
|
+
|
|
1416
1946
|
const session = readSession();
|
|
1417
1947
|
const owner = new Owner({
|
|
1418
1948
|
ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
|
|
1419
1949
|
site: session?.site ?? DEFAULT_DIRECTORY,
|
|
1420
1950
|
});
|
|
1421
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
|
+
|
|
1422
2006
|
const server = createServer(engine, {
|
|
1423
2007
|
web,
|
|
1424
2008
|
media: options.media,
|
|
@@ -1434,7 +2018,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
1434
2018
|
paywall,
|
|
1435
2019
|
ffmpeg: tools.ffmpeg,
|
|
1436
2020
|
load: (next) => loadSource(tools, next),
|
|
1437
|
-
...(
|
|
2021
|
+
...(directory ? { directory } : {}),
|
|
2022
|
+
...(follows ? { follows, vapidPublicKey } : {}),
|
|
2023
|
+
...(partyLine ? { partyLine } : {}),
|
|
1438
2024
|
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
1439
2025
|
// laptop has nobody to be an account of.
|
|
1440
2026
|
...(options.directory && process.env["DATABASE_URL"]
|
|
@@ -1588,6 +2174,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
1588
2174
|
|
|
1589
2175
|
if (options.publish !== "no" && publishable_) {
|
|
1590
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);
|
|
1591
2181
|
const wanted = options.publish === "yes"
|
|
1592
2182
|
? true
|
|
1593
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.`);
|
|
@@ -1597,7 +2187,17 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
1597
2187
|
directory: DEFAULT_DIRECTORY,
|
|
1598
2188
|
name: options.name || hostname(),
|
|
1599
2189
|
url: listen,
|
|
2190
|
+
audio,
|
|
1600
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
|
+
},
|
|
1601
2201
|
nowPlaying: () => {
|
|
1602
2202
|
const snapshot = engine.snapshot();
|
|
1603
2203
|
return snapshot.tracks[snapshot.index]?.title ?? "";
|