nixamp 0.16.0 → 0.17.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.
Files changed (106) hide show
  1. package/README.md +139 -0
  2. package/dist/audio.d.ts +70 -1
  3. package/dist/audio.js +84 -5
  4. package/dist/channels.d.ts +107 -3
  5. package/dist/channels.js +151 -3
  6. package/dist/compression/analyze.d.ts +105 -0
  7. package/dist/compression/analyze.js +213 -0
  8. package/dist/compression/blocks.d.ts +35 -0
  9. package/dist/compression/blocks.js +73 -0
  10. package/dist/compression/cli.d.ts +1 -0
  11. package/dist/compression/cli.js +347 -0
  12. package/dist/compression/codec.d.ts +66 -0
  13. package/dist/compression/codec.js +178 -0
  14. package/dist/compression/envelope.d.ts +77 -0
  15. package/dist/compression/envelope.js +191 -0
  16. package/dist/compression/jobs.d.ts +59 -0
  17. package/dist/compression/jobs.js +120 -0
  18. package/dist/compression/metrics.d.ts +62 -0
  19. package/dist/compression/metrics.js +64 -0
  20. package/dist/compression/policy.d.ts +76 -0
  21. package/dist/compression/policy.js +148 -0
  22. package/dist/compression/receiver.d.ts +51 -0
  23. package/dist/compression/receiver.js +92 -0
  24. package/dist/compression/relay.d.ts +134 -0
  25. package/dist/compression/relay.js +430 -0
  26. package/dist/compression/routes.d.ts +25 -0
  27. package/dist/compression/routes.js +266 -0
  28. package/dist/compression/service.d.ts +162 -0
  29. package/dist/compression/service.js +488 -0
  30. package/dist/compression/static.d.ts +65 -0
  31. package/dist/compression/static.js +248 -0
  32. package/dist/compression/store.d.ts +36 -0
  33. package/dist/compression/store.js +119 -0
  34. package/dist/compression/ts-transform.d.ts +44 -0
  35. package/dist/compression/ts-transform.js +148 -0
  36. package/dist/hls.d.ts +36 -3
  37. package/dist/hls.js +85 -11
  38. package/dist/layouts.d.ts +10 -0
  39. package/dist/layouts.js +71 -1
  40. package/dist/live-api.d.ts +16 -2
  41. package/dist/live-api.js +152 -19
  42. package/dist/live-events.d.ts +57 -2
  43. package/dist/live-events.js +243 -11
  44. package/dist/main.js +47 -0
  45. package/dist/mcp.d.ts +40 -0
  46. package/dist/mcp.js +255 -0
  47. package/dist/oauth-api.d.ts +50 -0
  48. package/dist/oauth-api.js +381 -0
  49. package/dist/oauth-server.d.ts +174 -0
  50. package/dist/oauth-server.js +559 -0
  51. package/dist/party.d.ts +32 -0
  52. package/dist/party.js +196 -0
  53. package/dist/playlist.d.ts +11 -0
  54. package/dist/playlist.js +27 -6
  55. package/dist/server.d.ts +37 -0
  56. package/dist/server.js +277 -45
  57. package/dist/sources.d.ts +16 -8
  58. package/dist/sources.js +102 -0
  59. package/dist/tickets.d.ts +67 -0
  60. package/dist/tickets.js +156 -0
  61. package/dist/tokens.d.ts +27 -2
  62. package/dist/tokens.js +42 -1
  63. package/dist/watch-party.d.ts +127 -0
  64. package/dist/watch-party.js +301 -0
  65. package/package.json +1 -1
  66. package/src/audio.ts +119 -6
  67. package/src/channels.ts +177 -7
  68. package/src/compression/analyze.ts +270 -0
  69. package/src/compression/blocks.ts +93 -0
  70. package/src/compression/cli.ts +340 -0
  71. package/src/compression/codec.ts +202 -0
  72. package/src/compression/envelope.ts +237 -0
  73. package/src/compression/jobs.ts +157 -0
  74. package/src/compression/metrics.ts +109 -0
  75. package/src/compression/policy.ts +171 -0
  76. package/src/compression/receiver.ts +107 -0
  77. package/src/compression/relay.ts +462 -0
  78. package/src/compression/routes.ts +294 -0
  79. package/src/compression/service.ts +525 -0
  80. package/src/compression/static.ts +264 -0
  81. package/src/compression/store.ts +126 -0
  82. package/src/compression/ts-transform.ts +148 -0
  83. package/src/hls.ts +100 -10
  84. package/src/layouts.ts +78 -1
  85. package/src/live-api.ts +172 -17
  86. package/src/live-events.ts +269 -11
  87. package/src/main.ts +47 -0
  88. package/src/mcp.ts +282 -0
  89. package/src/oauth-api.ts +481 -0
  90. package/src/oauth-server.ts +653 -0
  91. package/src/party.ts +216 -0
  92. package/src/playlist.ts +28 -5
  93. package/src/server.ts +296 -44
  94. package/src/sources.ts +100 -0
  95. package/src/tickets.ts +195 -0
  96. package/src/tokens.ts +50 -3
  97. package/src/watch-party.ts +390 -0
  98. package/web/dist/assets/{hls-3VKVEQE3-CrILISPJ.js → hls-3VKVEQE3-eV54kXE3.js} +1 -1
  99. package/web/dist/assets/index-CurZFzlH.css +1 -0
  100. package/web/dist/assets/index-DDzutJ75.js +1 -0
  101. package/web/dist/assets/{mpegts-CWeN63eG.js → mpegts-Buc3Odv6.js} +1 -1
  102. package/web/dist/assets/{mpegts-LO6RVLD6-DaMgRvlO.js → mpegts-LO6RVLD6-DcDKPB4P.js} +1 -1
  103. package/web/dist/index.html +59 -6
  104. package/web/dist/sw.js +6 -6
  105. package/web/dist/assets/index-CQ_m5HqS.css +0 -1
  106. package/web/dist/assets/index-CTxPM5KS.js +0 -1
package/src/server.ts CHANGED
@@ -45,6 +45,9 @@ import {
45
45
  signInFailedPage,
46
46
  SignIn,
47
47
  } from "./oauth.ts";
48
+ import { AuthorizationServer, clientsFrom } from "./oauth-server.ts";
49
+ import { handleOAuthApi, oauthApiPath } from "./oauth-api.ts";
50
+ import { WatchParties } from "./watch-party.ts";
48
51
  import { needsAdmin, needsMember, Owner } from "./owner.ts";
49
52
  import { createHash as sha } from "node:crypto";
50
53
  import { playJingle } from "./jingle.ts";
@@ -52,7 +55,9 @@ import { stateDir } from "./daemon.ts";
52
55
  import { readSession } from "./session.ts";
53
56
  import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
54
57
  import { PartyLine, telnyxSms } from "./partyline.ts";
55
- import { HlsPackagers, withKey } from "./hls.ts";
58
+ import { HlsPackagers, segmentType, withKey } from "./hls.ts";
59
+ import { CompressionService } from "./compression/service.ts";
60
+ import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
56
61
  import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
57
62
  import {
58
63
  contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, mergeDownloadArgs, playableLink, resolveLink,
@@ -74,7 +79,8 @@ import { Durable } from "./durable.ts";
74
79
  import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
75
80
  import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.ts";
76
81
  import { handleLiveApi } from "./live-api.ts";
77
- import { LiveEvents, type LiveEvent } from "./live-events.ts";
82
+ import { LiveEvents, eventStructuredData, isTicketed, type LiveEvent } from "./live-events.ts";
83
+ import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.ts";
78
84
  import { Layouts } from "./layouts.ts";
79
85
  import { Rooms } from "./rooms.ts";
80
86
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
@@ -85,8 +91,8 @@ import {
85
91
  type PaywallConfig,
86
92
  paywallFromEnv,
87
93
  } from "./paywall.ts";
88
- import { isRemote, playsInBrowser, sourceLabel } from "./sources.ts";
89
- import { codecsOf, probeAsync, videoArgs, type Codecs } from "./audio.ts";
94
+ import { isRemote, isTransportStream, playsInBrowser, sourceLabel } from "./sources.ts";
95
+ import { codecsOf, probeAsync, transportInputArgs, videoArgs, type Codecs } from "./audio.ts";
90
96
  import {
91
97
  allowedForListening,
92
98
  elevate,
@@ -550,11 +556,21 @@ export function toRemoteTracks(tracks: Loaded[]): RemoteTrack[] {
550
556
  }
551
557
 
552
558
  /** Video containers, as opposed to the songs that are most of a library. */
553
- const PICTURE = new Set([".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm", ".mpg", ".mpeg", ".wmv", ".flv"]);
559
+ const PICTURE = new Set([
560
+ ".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm", ".mpg", ".mpeg", ".wmv", ".flv",
561
+ // Raw transport streams: a recording off a card, a receiver or an IPTV
562
+ // dump, which is 1080p or 4K television and was arriving as its own
563
+ // soundtrack because none of these names were on this list.
564
+ ".m2ts", ".mts", ".m2t", ".trp", ".tp",
565
+ ]);
554
566
 
555
567
  export function hasPicture(path: string): boolean {
556
568
  const dot = path.lastIndexOf(".");
557
- return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
569
+ if (dot > 0 && PICTURE.has(path.slice(dot).toLowerCase())) return true;
570
+ // A `.ts` is a transport stream or a TypeScript file, and only its first
571
+ // bytes know which. Asked of the file rather than of the name, and the
572
+ // answer is remembered, because this is asked once per track per listing.
573
+ return isTransportStream(path);
558
574
  }
559
575
 
560
576
  /**
@@ -1042,6 +1058,22 @@ export interface KnownSource {
1042
1058
  export const PROBE_TRIES = 3;
1043
1059
  export const PROBE_RETRY_MS = 1500;
1044
1060
 
1061
+ /**
1062
+ * Whether a channel may carry H.265 as it is.
1063
+ *
1064
+ * A channel has one encode and many viewers, so it has to be something they
1065
+ * can all play, and H.265 is not that: Safari and televisions decode it,
1066
+ * Chrome on a desktop mostly does not and shows nothing rather than saying
1067
+ * so. So an HEVC source is re-encoded by default -- to 1080p H.264, because a
1068
+ * 4K re-encode does not keep up with playback -- and an operator whose
1069
+ * audience is phones and televisions can say `NIXAMP_HEVC_CHANNELS=1` and
1070
+ * have the 4K copied through untouched. A single viewer asking for a file
1071
+ * over /api/media is a different matter: there the browser says for itself.
1072
+ */
1073
+ export function hevcChannelsAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
1074
+ return env["NIXAMP_HEVC_CHANNELS"] === "1";
1075
+ }
1076
+
1045
1077
  export async function pullChannel(
1046
1078
  channels: Channels,
1047
1079
  ffprobe: string[],
@@ -1092,16 +1124,27 @@ export async function pullChannel(
1092
1124
  // from the first, the sound from the second. With one input ffmpeg
1093
1125
  // picks for itself, as it always did.
1094
1126
  ...(audio ? ["-map", "0:v:0", "-map", "1:a:0"] : []),
1095
- ...videoArgs(codecs),
1127
+ ...videoArgs(codecs, 0, { allowHevc: hevcChannelsAllowed() }),
1096
1128
  ]
1097
1129
  // No picture in it, so none is invented: MP3 is the thing every browser
1098
1130
  // plays and the thing a listener can join halfway through.
1099
1131
  : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
1132
+ // A transport stream is read further into before it is decoded, and given
1133
+ // the timestamps a recording cut mid-stream does not carry. Ahead of the
1134
+ // caller's own input arguments, which are headers for the address itself.
1135
+ const opening = [...transportInputArgs(source, codecs.container), ...input];
1100
1136
  const channel = channels.pull(
1101
- id, name, source, encode, kind, true, undefined, input, kind === "video" ? audio : "",
1137
+ id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "",
1102
1138
  { live, position: known.position ?? 0 },
1103
1139
  );
1104
1140
  if (channel && !assumed) channel.info.codecs = codecs;
1141
+ // What comes out, as opposed to what went in. An H.265 source copied
1142
+ // through stays H.265; one re-encoded arrives as H.264, and a packager
1143
+ // told otherwise would cut fMP4 segments for a stream that did not need
1144
+ // them.
1145
+ if (channel && kind === "video") {
1146
+ channel.info.emits = encode.includes("libx264") ? "h264" : codecs.video || "h264";
1147
+ }
1105
1148
  return channel;
1106
1149
  }
1107
1150
 
@@ -1220,6 +1263,10 @@ export function isSignInPath(path: string): boolean {
1220
1263
  // Public to read, so it must not be behind a share key either.
1221
1264
  path === "/api/v1/opendirs" ||
1222
1265
  path.startsWith("/api/v1/opendirs/") ||
1266
+ // nixamp as an authorization server: a client arriving here has no share
1267
+ // key and is not asking for one, and the metadata document is public by
1268
+ // the RFC that defines where it lives.
1269
+ oauthApiPath(path) ||
1223
1270
  OAUTH_ROUTE.test(path)
1224
1271
  );
1225
1272
  }
@@ -1242,29 +1289,34 @@ function htmlText(value: string): string {
1242
1289
  .replaceAll("'", "'");
1243
1290
  }
1244
1291
 
1292
+ /**
1293
+ * Whose site this shell belongs to.
1294
+ *
1295
+ * Read from the page rather than hard-coded, because one NixAmp serves several
1296
+ * branded clients and each brings its own head. og:site_name first, then the
1297
+ * part of the title before the dash, then the hostname.
1298
+ */
1299
+ export function brandOf(shell: string, site: string): string {
1300
+ const named = /<meta\s+property="og:site_name"\s+content="([^"]+)"/i.exec(shell);
1301
+ if (named?.[1]) return named[1];
1302
+ const titled = /<title>([^<]*)<\/title>/i.exec(shell);
1303
+ const head = titled?.[1]?.split(/\s[—-]\s/)[0]?.trim();
1304
+ if (head) return head;
1305
+ try {
1306
+ return new URL(site).hostname.replace(/^www\./, "");
1307
+ } catch {
1308
+ return "NixAmp";
1309
+ }
1310
+ }
1311
+
1245
1312
  function eventDocument(shell: string, event: Awaited<ReturnType<LiveEvents["get"]>>, site: string): string {
1246
1313
  if (!event) return shell;
1247
- const title = `${event.title} — BackToSchool.help`;
1248
- const description = event.description || `Listen to ${event.title} live on BackToSchool.help.`;
1314
+ const brand = brandOf(shell, site);
1315
+ const title = `${event.title} — ${brand}`;
1316
+ const kindWord = event.kind === "concert" ? "Watch" : "Listen to";
1317
+ const description = event.description || `${kindWord} ${event.title} live on ${brand}.`;
1249
1318
  const canonical = `${site.replace(/\/$/, "")}/live/${encodeURIComponent(event.slug)}`;
1250
- const structured = JSON.stringify({
1251
- "@context": "https://schema.org",
1252
- "@type": "Event",
1253
- name: event.title,
1254
- description,
1255
- eventStatus: event.status === "cancelled"
1256
- ? "https://schema.org/EventCancelled"
1257
- : event.status === "live"
1258
- ? "https://schema.org/EventInProgress"
1259
- : event.status === "ended"
1260
- ? "https://schema.org/EventCompleted"
1261
- : "https://schema.org/EventScheduled",
1262
- eventAttendanceMode: "https://schema.org/OnlineEventAttendanceMode",
1263
- ...(event.startsAt ? { startDate: event.startsAt } : {}),
1264
- ...(event.endsAt ? { endDate: event.endsAt } : {}),
1265
- url: canonical,
1266
- location: { "@type": "VirtualLocation", url: canonical },
1267
- }).replaceAll("<", "\\u003c");
1319
+ const structured = JSON.stringify(eventStructuredData(event, canonical)).replaceAll("<", "\\u003c");
1268
1320
  return shell
1269
1321
  .replace(/<title>.*?<\/title>/s, `<title>${htmlText(title)}</title>`)
1270
1322
  .replace(/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${htmlText(description)}" />`)
@@ -1316,6 +1368,8 @@ export interface HandlerOptions {
1316
1368
  ytdlp?: string[] | null;
1317
1369
  /** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
1318
1370
  hls?: HlsPackagers;
1371
+ /** Lossless relay compression, its policies, diagnostics and static representations. */
1372
+ compression?: CompressionService;
1319
1373
  /** What a name is -- a film, a channel, a fixture -- asked of nichedb.dev and remembered. */
1320
1374
  enricher?: Enricher;
1321
1375
  /** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
@@ -1440,6 +1494,16 @@ export interface HandlerOptions {
1440
1494
  layouts?: Layouts;
1441
1495
  /** Persistent participation state that must not be coupled to live audio. */
1442
1496
  rooms?: Rooms;
1497
+ /** Tickets: a paid pass to one event's room. Absent means every show is free. */
1498
+ tickets?: Tickets;
1499
+ /**
1500
+ * nixamp as an OAuth 2.1 authorization server, so another site can act on
1501
+ * an account here. nixamp.com only: a nixamp on a laptop keeps no accounts
1502
+ * and so has nobody to authorize.
1503
+ */
1504
+ authServer?: AuthorizationServer;
1505
+ /** Watch parties bridged from a client site into nixamp rooms. */
1506
+ parties?: WatchParties;
1443
1507
  /**
1444
1508
  * How an invite is sent: by email, by text, and the site the watch link is
1445
1509
  * built on. nixamp.com only; a personal nixamp has no mail to send from.
@@ -1514,6 +1578,24 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1514
1578
  }
1515
1579
  return channelEvent;
1516
1580
  };
1581
+ // What the compression routes need from this handler: how to answer,
1582
+ // how to count a stream, and whether this caller holds the controls.
1583
+ const compressionCtx = (): RouteContext | null =>
1584
+ options.compression
1585
+ ? {
1586
+ service: options.compression,
1587
+ json,
1588
+ readBody: (incoming) => readBody(incoming),
1589
+ cors: CORS,
1590
+ watch: (incoming, outgoing, track) => watch(incoming, outgoing, "stream", track),
1591
+ controls: async () => {
1592
+ if (key === null) return true;
1593
+ if (scopeOf(keyFrom(request, url), key, null) === "control") return true;
1594
+ if (options.owner) return (await options.owner.check(false, tokenFrom(request.headers))).allowed;
1595
+ return false;
1596
+ },
1597
+ }
1598
+ : null;
1517
1599
 
1518
1600
  if (request.method === "OPTIONS") {
1519
1601
  response.writeHead(204, CORS);
@@ -1555,11 +1637,23 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1555
1637
  return;
1556
1638
  }
1557
1639
 
1640
+ // nixamp as an authorization server, and the watch parties that pairing
1641
+ // exists for. Before the share-key check for the same reason sign-in is:
1642
+ // a client site holds an account's token, never a server's key.
1643
+ if (options.authServer && options.accounts && await handleOAuthApi(request, response, url, {
1644
+ server: options.authServer,
1645
+ accounts: options.accounts,
1646
+ ...(options.parties ? { parties: options.parties } : {}),
1647
+ ...(options.handles ? { handles: options.handles } : {}),
1648
+ secureCookies: options.secureCookies ?? false,
1649
+ })) return;
1650
+
1558
1651
  if (options.events && await handleLiveApi(request, response, url, {
1559
1652
  events: options.events,
1560
1653
  ...(options.accounts ? { accounts: options.accounts } : {}),
1561
1654
  ...(options.layouts ? { layouts: options.layouts } : {}),
1562
1655
  ...(options.rooms ? { rooms: options.rooms } : {}),
1656
+ ...(options.tickets ? { tickets: options.tickets } : {}),
1563
1657
  ...(options.invites?.site ? { site: options.invites.site } : {}),
1564
1658
  ...(options.invites?.email ? { email: options.invites.email } : {}),
1565
1659
  })) return;
@@ -3011,7 +3105,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3011
3105
  watch(request, response, "stream", entry.title);
3012
3106
  const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, entry.source);
3013
3107
  if (codecs.video !== "") {
3014
- pipeFfmpeg(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
3108
+ pipeFfmpeg(
3109
+ request, response, entry.source, options.ffmpeg ?? ["ffmpeg"],
3110
+ videoArgs(codecs, 0, { allowHevc: url.searchParams.get("hevc") === "1" }), "video/mp4",
3111
+ transportInputArgs(entry.source, codecs.container),
3112
+ );
3015
3113
  } else {
3016
3114
  transcode(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"]);
3017
3115
  }
@@ -3246,6 +3344,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3246
3344
  // A channel is one publisher and everybody listening to them. Two or three
3247
3345
  // devices can publish at once, each to their own channel, and a listener
3248
3346
  // picks which to hear.
3347
+ // The server's compression at a glance, its switch, and the analyses.
3348
+ if (path.startsWith("/api/compression")) {
3349
+ const compression = compressionCtx();
3350
+ if (compression && (await handleCompressionApi(request, response, compression, path))) return;
3351
+ }
3352
+
3249
3353
  if (path === "/api/channels" && options.channels) {
3250
3354
  // Without the source. Anyone holding the listen link may ask what is
3251
3355
  // on, and the address a channel is pulled from is the one thing about
@@ -3273,17 +3377,40 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3273
3377
  json(response, 404, { error: "nothing is playing on that channel" });
3274
3378
  return;
3275
3379
  }
3276
- } else if (!options.events!.canManage(event, account?.id)) {
3277
- json(response, account ? 403 : 401, { error: account ? "only the event host can publish here" : "sign in to host this event" });
3380
+ // A ticketed room is the one place a 402 belongs on the media
3381
+ // itself: there is a thing to buy, it is this, and the answer says
3382
+ // what it costs rather than pretending the room is not there.
3383
+ if (isTicketed(event)) {
3384
+ const held = await options.tickets?.holds(event, ticketFrom(request.headers, url)) ?? false;
3385
+ if (needsTicket(event, account?.id, held)) {
3386
+ json(response, 402, {
3387
+ error: `${event.title} is a ticketed show.`,
3388
+ ticket: options.tickets?.offer(event) ?? {
3389
+ ticketed: true,
3390
+ priceCents: event.ticketPriceCents,
3391
+ currency: event.ticketCurrency,
3392
+ },
3393
+ });
3394
+ return;
3395
+ }
3396
+ }
3397
+ } else if (!options.events!.canPerform(event, account?.id)) {
3398
+ json(response, account ? 403 : 401, { error: account ? "only the people on this stage can publish here" : "sign in to host this event" });
3278
3399
  return;
3279
3400
  } else if (request.method === "POST" &&
3280
3401
  (action === undefined || action === "chunk") &&
3281
- event.status !== "live") {
3402
+ event.status !== "live" && event.status !== "encore") {
3282
3403
  json(response, 409, { error: "start the event before publishing audio" });
3283
3404
  return;
3284
3405
  }
3285
3406
  }
3286
3407
 
3408
+ // Its compression policy, an analysis of it, and its relay to or from
3409
+ // another nixamp. The ordinary playback URL above is untouched by any
3410
+ // of it: a relay is a different media type on a different path.
3411
+ const compression = compressionCtx();
3412
+ if (compression && (await handleChannelCompression(request, response, compression, id, action, file))) return;
3413
+
3287
3414
  // The same channel as HLS: a playlist of short files, which is what
3288
3415
  // Safari on an iPhone plays live -- it will not take the endless MP4
3289
3416
  // below, and spun on it a few times before giving up. Packaged on
@@ -3298,7 +3425,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3298
3425
  return;
3299
3426
  }
3300
3427
  if (file === "index.m3u8") {
3301
- const playlist = await options.hls.playlist(id);
3428
+ // A channel carrying H.265 is cut into fMP4 rather than transport
3429
+ // segments: HLS in TS is defined for H.264 only, and Safari plays
3430
+ // an HEVC channel packaged as TS as sound over a black screen.
3431
+ const playlist = await options.hls.playlist(id, channels.info(id)?.emits === "hevc");
3302
3432
  if (playlist === null) {
3303
3433
  json(response, 503, { error: "that channel could not be packaged as HLS yet; try again in a moment" });
3304
3434
  return;
@@ -3321,7 +3451,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3321
3451
  watch(request, response, "stream", id);
3322
3452
  response.writeHead(200, {
3323
3453
  ...CORS,
3324
- "content-type": "video/mp2t",
3454
+ // By its name: a TS segment, an fMP4 media segment, or the init.
3455
+ "content-type": segmentType(file ?? ""),
3325
3456
  "cache-control": "no-store",
3326
3457
  "content-length": statSync(segment).size,
3327
3458
  });
@@ -3365,14 +3496,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3365
3496
  }
3366
3497
 
3367
3498
  if (action === undefined && request.method === "DELETE") {
3368
- // Asked once: the second call would answer false, having just stopped
3369
- // the thing it was asking about.
3370
3499
  // A member takes off what they put on, and nothing else.
3371
3500
  if (liveBy && channels.info(id)?.startedBy !== liveBy) {
3372
3501
  json(response, 403, { error: "that stream is not yours to take off" });
3373
3502
  return;
3374
3503
  }
3375
- const stopped = channels.stop(id);
3504
+ // A channel fed by another nixamp's relay is taken off by stopping
3505
+ // the relay, which takes the channel with it; otherwise it would
3506
+ // dial again the moment the channel went. Asked once either way:
3507
+ // the second call would answer false, having just stopped the
3508
+ // thing it was asking about.
3509
+ const stopped = (options.compression?.stopPull(id) ?? false) || channels.stop(id);
3376
3510
  // Taken off on purpose is forgotten on purpose: it must not come back
3377
3511
  // at the next restart.
3378
3512
  if (stopped) {
@@ -3380,7 +3514,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3380
3514
  // Off the air is news too: told now, so the directory drops it
3381
3515
  // rather than listing it until the next heartbeat.
3382
3516
  void options.live?.announce?.();
3383
- if (event && event.status === "live") {
3517
+ if (event && (event.status === "live" || event.status === "encore")) {
3384
3518
  await options.events?.transition(event.id, event.ownerId, "ended", event.version).catch(() => undefined);
3385
3519
  }
3386
3520
  }
@@ -3883,6 +4017,30 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3883
4017
  return;
3884
4018
  }
3885
4019
 
4020
+ // A library file in the relay envelope: the same bytes as /api/media/N,
4021
+ // framed and compressed once and kept. The original stays where it is
4022
+ // and keeps answering ranges; this cannot be asked for one.
4023
+ const staticRelay = /^\/api\/media\/(\d+)\/relay$/.exec(path);
4024
+ if (staticRelay && request.method === "GET") {
4025
+ const compression = compressionCtx();
4026
+ if (!compression) {
4027
+ json(response, 503, { error: "this server keeps no relay representations" });
4028
+ return;
4029
+ }
4030
+ if (!options.media) {
4031
+ json(response, 403, { error: "media streaming is off" });
4032
+ return;
4033
+ }
4034
+ const index = Number(staticRelay[1]);
4035
+ const file = engine.trackPath(index);
4036
+ if (file === undefined) {
4037
+ json(response, 404, { error: "no such track" });
4038
+ return;
4039
+ }
4040
+ handleStaticRelay(request, response, compression, file, engine.snapshot().tracks?.[index]?.title ?? file);
4041
+ return;
4042
+ }
4043
+
3886
4044
  if (path.startsWith("/api/media/")) {
3887
4045
  if (!options.media) {
3888
4046
  json(response, 403, { error: "media streaming is off" });
@@ -3904,6 +4062,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3904
4062
  // and above 20 megabits the original was always the better answer.
3905
4063
  const asked = Number(url.searchParams.get("kbps") ?? "");
3906
4064
  const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
4065
+ // Whether this browser decodes H.265, which only it can know: Safari and
4066
+ // televisions do, Chrome on a desktop does not and says nothing when
4067
+ // handed it. Copying a 4K HEVC film is free; re-encoding one does not
4068
+ // keep up with playing it, so the answer is worth carrying in the URL.
4069
+ const allowHevc = url.searchParams.get("hevc") === "1";
3907
4070
 
3908
4071
  if (playsInBrowser(file) && capKbps === 0) {
3909
4072
  sendFile(request, response, file);
@@ -3922,7 +4085,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3922
4085
  // added by an older nixamp goes to an audio element for ever, and
3923
4086
  // the only cure is noticing and adding it again.
3924
4087
  engine.sawPicture(index);
3925
- pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
4088
+ pipeFfmpeg(
4089
+ request, response, file, options.ffmpeg ?? ["ffmpeg"],
4090
+ videoArgs(codecs, capKbps, { allowHevc }), "video/mp4",
4091
+ transportInputArgs(file, codecs.container),
4092
+ );
3926
4093
  return;
3927
4094
  }
3928
4095
  }
@@ -4240,6 +4407,12 @@ function pipeFfmpeg(
4240
4407
  ffmpeg: string[],
4241
4408
  outputArgs: string[],
4242
4409
  contentType: string,
4410
+ /**
4411
+ * What to say before the input is opened. A transport stream needs telling
4412
+ * how far to read before it decides what is in it, and to make up the
4413
+ * timestamps a recording cut mid-stream does not have.
4414
+ */
4415
+ inputArgs: string[] = [],
4243
4416
  ): void {
4244
4417
  const [command, ...prefix] = ffmpeg as [string, ...string[]];
4245
4418
  const child = spawn(
@@ -4252,6 +4425,7 @@ function pipeFfmpeg(
4252
4425
  // belong to the http protocol, and ffmpeg rejects the whole command
4253
4426
  // when they are handed to it for a file on disk.
4254
4427
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
4428
+ ...inputArgs,
4255
4429
  "-i", source,
4256
4430
  ...outputArgs,
4257
4431
  "-",
@@ -4469,10 +4643,22 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4469
4643
  cacheFile: join(stateDir(), "enrich.json"),
4470
4644
  onEvent: (message) => console.log(message),
4471
4645
  });
4646
+ // Off unless a policy says otherwise: every channel plays exactly as it
4647
+ // did, and a relay is a thing another nixamp asks for by name.
4648
+ const compression = new CompressionService({
4649
+ channels,
4650
+ stateDir: stateDir(),
4651
+ port: options.port,
4652
+ ffprobe: tools.ffprobe,
4653
+ cacheDir: join(stateDir(), "relay-cache"),
4654
+ onEvent: (message) => console.log(message),
4655
+ });
4472
4656
  const hls = new HlsPackagers({
4473
4657
  ffmpeg: tools.ffmpeg,
4474
4658
  listen: (id, listener) => channels.listen(id, listener),
4475
4659
  onEvent: (message) => console.log(message),
4660
+ // TS unless the channel's policy, or the server's, asks for fMP4.
4661
+ packaging: (id) => compression.packagingOf(id),
4476
4662
  });
4477
4663
 
4478
4664
  // The channels this server was carrying when it was last stopped, put back
@@ -4536,9 +4722,67 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4536
4722
  : undefined;
4537
4723
  const follows = pool ? new Follows(pool) : undefined;
4538
4724
  const favorites = pool ? new Favorites(pool) : undefined;
4725
+ const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
4539
4726
  const events = pool ? new LiveEvents(pool) : undefined;
4540
4727
  const layouts = pool ? new Layouts(pool) : undefined;
4541
4728
  const rooms = pool ? new Rooms(pool) : undefined;
4729
+ // Tickets need somewhere for the money to go and a key to settle it with.
4730
+ // Without a CoinPay key every event is simply a free one.
4731
+ const ticketConfig = events
4732
+ ? ticketsFromEnv(process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY)
4733
+ : null;
4734
+ const tickets = ticketConfig ? new Tickets(ticketConfig) : undefined;
4735
+
4736
+ // Accounts live where the directory lives, and only there: a nixamp on a
4737
+ // laptop has nobody to be an account of. Made here rather than inline in
4738
+ // the handler options because the authorization server issues its access
4739
+ // tokens out of the same table -- an OAuth token IS a nixamp token with a
4740
+ // client's name on it, which is why every existing route understands one.
4741
+ const accounts =
4742
+ options.directory && process.env["DATABASE_URL"]
4743
+ ? new Accounts({
4744
+ connectionString: process.env["DATABASE_URL"],
4745
+ secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
4746
+ })
4747
+ : undefined;
4748
+
4749
+ // nixamp as an OAuth 2.1 authorization server, and the watch parties a
4750
+ // client site bridges through it. Both need the same three things -- a
4751
+ // database, accounts, and a site to be the issuer of -- so both appear or
4752
+ // neither does.
4753
+ const authServer =
4754
+ pool && accounts?.tokens
4755
+ ? new AuthorizationServer({
4756
+ db: pool,
4757
+ tokens: accounts.tokens,
4758
+ clients: clientsFrom(process.env),
4759
+ issuer: nixampSite,
4760
+ })
4761
+ : undefined;
4762
+ const parties =
4763
+ pool && events && authServer
4764
+ ? new WatchParties({
4765
+ db: pool,
4766
+ events,
4767
+ site: nixampSite,
4768
+ // A client may only point a watch link at its own site, which is
4769
+ // read off the redirect URIs it registered rather than configured
4770
+ // twice. Without it, "come and watch" could be sent anywhere.
4771
+ hostsFor: (origin) => {
4772
+ const client = authServer.client(origin);
4773
+ if (!client) return [];
4774
+ const hosts = new Set<string>();
4775
+ for (const uri of client.redirectUris) {
4776
+ try {
4777
+ hosts.add(new URL(uri).hostname);
4778
+ } catch {
4779
+ // A malformed registration names no host, which allows none.
4780
+ }
4781
+ }
4782
+ return [...hosts];
4783
+ },
4784
+ })
4785
+ : undefined;
4542
4786
 
4543
4787
  // Names and certificates for signed-in servers, and the rate limit over
4544
4788
  // everything. All of it is nixamp.com's business: the DNS keys live only
@@ -4579,6 +4823,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4579
4823
  { path: "/api/v1/invite", limit: 10 },
4580
4824
  { path: "/api/v1/dns", limit: 30 },
4581
4825
  { path: "/api/v1/certs", limit: 30 },
4826
+ // A token endpoint is where a stolen code or refresh token would be
4827
+ // tried, so it is address-bucketed: a client presenting its own id
4828
+ // must not get the credentialed budget to guess with.
4829
+ { path: "/api/v1/oauth/token", limit: 30, credential: false },
4830
+ { path: "/api/v1/oauth/", limit: 60, credential: false },
4831
+ { path: "/api/v1/watch-parties", limit: 60 },
4582
4832
  { path: "/api/health", open: true },
4583
4833
  { path: "/api/directory", limit: 120 },
4584
4834
  ],
@@ -4869,6 +5119,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4869
5119
  ytdlp: tools.ytdlp ?? null,
4870
5120
  cookies: cookiesFile(),
4871
5121
  hls,
5122
+ compression,
4872
5123
  enricher,
4873
5124
  ...(tls ? { tls } : {}),
4874
5125
  // Untagged, so a directory of five thousand files answers at once; the
@@ -4881,6 +5132,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4881
5132
  ...(events ? { events } : {}),
4882
5133
  ...(layouts ? { layouts } : {}),
4883
5134
  ...(rooms ? { rooms } : {}),
5135
+ ...(tickets ? { tickets } : {}),
5136
+ ...(authServer ? { authServer } : {}),
5137
+ ...(parties ? { parties } : {}),
4884
5138
  // Invites go out the same way follow notifications do, and only from a
4885
5139
  // site that has somebody to send them for.
4886
5140
  ...(pool
@@ -4915,12 +5169,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4915
5169
  ...(partyLine ? { partyLine } : {}),
4916
5170
  // Accounts live where the directory lives, and only there: a nixamp on a
4917
5171
  // laptop has nobody to be an account of.
4918
- ...(options.directory && process.env["DATABASE_URL"]
5172
+ ...(accounts
4919
5173
  ? {
4920
- accounts: new Accounts({
4921
- connectionString: process.env["DATABASE_URL"],
4922
- secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
4923
- }),
5174
+ accounts,
4924
5175
  secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
4925
5176
  // A deployment reached over https is one behind somebody's proxy, so
4926
5177
  // the socket address is that proxy and the forwarded header is the
@@ -5318,6 +5569,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5318
5569
  const shutdown = (): void => {
5319
5570
  rtmp?.stop();
5320
5571
  enricher.save();
5572
+ compression.stopAll();
5321
5573
  hls.stopAll();
5322
5574
  channels.stopAll();
5323
5575
  ingest?.stopRtmp();