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/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 in both directions: anyone may read the list,
775
- // and anyone running a nixamp may add themselves to it. It is answered
776
- // before the key check, because a visitor to nixamp.com has no key and is
777
- // exactly who it is for.
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
- json(response, 200, { streams: options.directory.list(), now: Date.now() });
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
- json(response, 200, options.directory.announce(announcement));
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
- sendFile(request, response, file);
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
- ...(options.directory ? { directory: new Directory() } : {}),
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 ?? "";