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