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/sources.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * list; what needs care is telling the four apart, and telling an .m3u that
7
7
  * lists tracks from an HLS playlist that *is* one track.
8
8
  */
9
+ import { closeSync, openSync, readSync } from "node:fs";
9
10
 
10
11
  /** http and https only. ffmpeg speaks more, but these are what a link is. */
11
12
  export function isRemote(source: string): boolean {
@@ -155,3 +156,102 @@ export function playsInBrowser(source: string): boolean {
155
156
  const dot = path.lastIndexOf(".");
156
157
  return dot > 0 && WEB_READY.has(path.slice(dot));
157
158
  }
159
+
160
+ /**
161
+ * Transport streams, which is what a raw `.ts` file off a capture card, a
162
+ * satellite receiver or an IPTV recorder is.
163
+ *
164
+ * `.m2ts`, `.mts` and the rest name nothing else, so the extension is answer
165
+ * enough. `.ts` is the awkward one: it is also every TypeScript file in every
166
+ * repository on the machine, and this program is written in them. So a `.ts`
167
+ * is never taken on its name -- it is opened and asked, which costs one read
168
+ * of two kilobytes and is the only honest way to tell 4K television from a
169
+ * module.
170
+ */
171
+ const TRANSPORT_NAMES = new Set([".m2ts", ".mts", ".m2t", ".trp", ".tp", ".mpegts"]);
172
+
173
+ /** A `.ts`, which may be a transport stream and may be a TypeScript file. */
174
+ export function isAmbiguousTransportName(path: string): boolean {
175
+ return /\.ts$/i.test(isRemote(path) ? new URL(path).pathname : path);
176
+ }
177
+
178
+ /** An extension that means a transport stream and nothing else. */
179
+ export function isTransportName(path: string): boolean {
180
+ const file = isRemote(path) ? new URL(path).pathname : path;
181
+ const dot = file.lastIndexOf(".");
182
+ return dot > 0 && TRANSPORT_NAMES.has(file.slice(dot).toLowerCase());
183
+ }
184
+
185
+ /** How many bytes are read to decide. Three packets at the widest spacing, and room to find the first. */
186
+ const SNIFF = 2048;
187
+ /**
188
+ * Packet sizes in the wild: 188 is MPEG-TS, 192 is what Blu-ray and a lot of
189
+ * recorders write (a four-byte arrival timestamp in front of each packet), 204
190
+ * is 188 with Reed-Solomon parity from a DVB card.
191
+ */
192
+ const STRIDES = [188, 192, 204];
193
+
194
+ /**
195
+ * Whether these bytes are a transport stream: a 0x47 sync byte at the start of
196
+ * every packet.
197
+ *
198
+ * Three in a row at the same spacing, because one 0x47 in a file is the letter
199
+ * G. The first packet may not be at byte zero -- a recording cut mid-stream
200
+ * starts mid-packet -- so every offset within one packet is tried.
201
+ */
202
+ export function sniffTransportStream(head: Buffer): boolean {
203
+ for (const stride of STRIDES) {
204
+ for (let start = 0; start < stride; start++) {
205
+ if (start + stride * 2 >= head.length) break;
206
+ if (head[start] !== 0x47) continue;
207
+ if (head[start + stride] === 0x47 && head[start + stride * 2] === 0x47) return true;
208
+ }
209
+ }
210
+ return false;
211
+ }
212
+
213
+ /**
214
+ * Whether the file at this path is a transport stream.
215
+ *
216
+ * Remembered, because the answer is asked once per snapshot per track and a
217
+ * library listing must not turn into a read per file per frame. A file that
218
+ * changes under us is a file being written, and a stale answer about it is a
219
+ * track that plays rather than a listing that stalls.
220
+ */
221
+ const sniffed = new Map<string, boolean>();
222
+ const SNIFF_REMEMBERED = 5000;
223
+
224
+ export function looksLikeTransportStream(path: string): boolean {
225
+ if (isRemote(path)) return false;
226
+ const remembered = sniffed.get(path);
227
+ if (remembered !== undefined) return remembered;
228
+ let answer = false;
229
+ let fd: number | null = null;
230
+ try {
231
+ fd = openSync(path, "r");
232
+ const head = Buffer.alloc(SNIFF);
233
+ const read = readSync(fd, head, 0, SNIFF, 0);
234
+ answer = sniffTransportStream(head.subarray(0, read));
235
+ } catch {
236
+ answer = false;
237
+ } finally {
238
+ if (fd !== null) {
239
+ try {
240
+ closeSync(fd);
241
+ } catch {
242
+ // Closing a file that could not be opened is not a failure.
243
+ }
244
+ }
245
+ }
246
+ // A cap rather than a cache with eviction: a library of a million files
247
+ // must not be a million remembered answers, and forgetting costs one read.
248
+ if (sniffed.size >= SNIFF_REMEMBERED) sniffed.clear();
249
+ sniffed.set(path, answer);
250
+ return answer;
251
+ }
252
+
253
+ /** Whether this path is a transport stream, by name where the name is certain and by its bytes where it is not. */
254
+ export function isTransportStream(path: string): boolean {
255
+ if (isTransportName(path)) return true;
256
+ return isAmbiguousTransportName(path) && looksLikeTransportStream(path);
257
+ }
package/src/tickets.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tickets: a paid pass to one room.
3
+ *
4
+ * The same x402 machinery the crawler paywall runs on, pointed at a single
5
+ * event instead of at a whole server. A ticket is a signed token with an
6
+ * expiry and nothing else -- no table, no seat map, no order row -- which is
7
+ * what makes it usable from a client on another origin that holds no database.
8
+ *
9
+ * Three things are deliberate:
10
+ *
11
+ * - The signing secret is per event, derived from the deployment's secret and
12
+ * the event id. A ticket to Friday is therefore not a ticket to Saturday,
13
+ * and no revocation list is needed to make that true.
14
+ * - The money goes to the event's own `payTo`, so the performer is paid
15
+ * directly and nixamp is never in the middle of it.
16
+ * - A free event has no till at all: `sell` refuses rather than quoting 0.00,
17
+ * because a 402 nobody can satisfy is worse than an open door.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { createGateway, mintPass } from "@profullstack/x402-gateway";
21
+ import {
22
+ canPerformEvent,
23
+ isTicketed,
24
+ type LiveEvent,
25
+ } from "./live-events.ts";
26
+
27
+ /** Where a ticket rides. Same shape as the crawl pass, different name. */
28
+ export const TICKET_HEADER = "x-nixamp-ticket";
29
+
30
+ export interface TicketOptions {
31
+ /** A scoped CoinPay key with payments:create. Without it nothing sells. */
32
+ coinpayKey: string;
33
+ /** Pass signing secret. Defaults to the CoinPay key, as the gateway does. */
34
+ secret?: string;
35
+ /** The origin a buyer is quoted, which has to be one they can reach. */
36
+ site: string;
37
+ /** Injected by tests. */
38
+ fetch?: typeof fetch;
39
+ }
40
+
41
+ export interface TicketOffer {
42
+ ticketed: boolean;
43
+ priceCents: number;
44
+ currency: string;
45
+ minutes: number;
46
+ header: string;
47
+ buy: string;
48
+ /** The x402 body, for a client that pays rather than reads. */
49
+ offer: unknown;
50
+ }
51
+
52
+ /** The ticket a request presents: its own header, or the query for a browser. */
53
+ export function ticketFrom(
54
+ headers: Record<string, string | string[] | undefined>,
55
+ url?: URL,
56
+ ): string {
57
+ const raw = headers[TICKET_HEADER];
58
+ const header = Array.isArray(raw) ? raw[0] : raw;
59
+ if (header && header.trim()) return header.trim();
60
+ return url?.searchParams.get("ticket")?.trim() ?? "";
61
+ }
62
+
63
+ /**
64
+ * Whether this person still has to buy something before the room opens.
65
+ *
66
+ * Everyone on the stage is already in: an artist who had to buy a ticket to
67
+ * their own show would be a bug with a refund attached. So is the person who
68
+ * is holding a valid one.
69
+ */
70
+ export function needsTicket(
71
+ event: LiveEvent,
72
+ accountId: string | undefined,
73
+ hasTicket: boolean,
74
+ ): boolean {
75
+ if (!isTicketed(event)) return false;
76
+ if (canPerformEvent(event, accountId)) return false;
77
+ return !hasTicket;
78
+ }
79
+
80
+ export class Tickets {
81
+ private readonly built = new Map<string, ReturnType<typeof createGateway>>();
82
+
83
+ constructor(private readonly options: TicketOptions) {}
84
+
85
+ /** Whether tickets can actually be sold here, as opposed to merely priced. */
86
+ get enabled(): boolean {
87
+ return Boolean(this.options.coinpayKey);
88
+ }
89
+
90
+ /**
91
+ * The secret one event's tickets are signed with. Derived rather than
92
+ * stored: it has to be the same on every process that serves the event, and
93
+ * different for every event.
94
+ */
95
+ private secretFor(event: LiveEvent): string {
96
+ const base = this.options.secret || this.options.coinpayKey || "nixamp-tickets";
97
+ return createHash("sha256").update(`${base}:ticket:${event.id}`).digest("hex");
98
+ }
99
+
100
+ private gatewayFor(event: LiveEvent) {
101
+ const site = this.options.site.replace(/\/$/, "");
102
+ const key = `${site}|${event.id}|${event.ticketPriceCents}|${event.ticketCurrency}|${event.ticketMinutes}|${event.payTo ?? ""}`;
103
+ const known = this.built.get(key);
104
+ if (known) return known;
105
+ const gateway = createGateway({
106
+ siteUrl: site,
107
+ siteName: event.title,
108
+ payTo: event.payTo ?? "",
109
+ priceCents: event.ticketPriceCents,
110
+ currency: event.ticketCurrency,
111
+ passMinutes: event.ticketMinutes,
112
+ coinpay: { apiKey: this.options.coinpayKey },
113
+ secret: this.secretFor(event),
114
+ header: TICKET_HEADER,
115
+ path: `/live/${event.slug}/tickets`,
116
+ // Nothing here is about who is asking: a ticket is a ticket whatever
117
+ // the user agent says it is.
118
+ isPaidAgent: () => true,
119
+ benefits: [`Watch ${event.title} live, and the replay for ${event.ticketMinutes} minutes.`],
120
+ ...(this.options.fetch ? { fetch: this.options.fetch } : {}),
121
+ });
122
+ // Bounded, so a directory of thousands of events cannot grow a gateway
123
+ // per event and keep them all.
124
+ if (this.built.size > 200) this.built.clear();
125
+ this.built.set(key, gateway);
126
+ return gateway;
127
+ }
128
+
129
+ /** What a ticket costs and how to buy one, without answering a request. */
130
+ offer(event: LiveEvent): TicketOffer {
131
+ const site = this.options.site.replace(/\/$/, "");
132
+ return {
133
+ ticketed: isTicketed(event),
134
+ priceCents: event.ticketPriceCents,
135
+ currency: event.ticketCurrency,
136
+ minutes: event.ticketMinutes,
137
+ header: TICKET_HEADER,
138
+ buy: `${site}/live/${event.slug}/tickets`,
139
+ offer: isTicketed(event) ? this.gatewayFor(event).offer(1) : { x402Version: 2, accepts: [] },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * A ticket nobody paid for: the guest list.
145
+ *
146
+ * Every venue has one, and a show that cannot comp the press, the support
147
+ * act's friends or a refunded buyer is a show whose only answer to a
148
+ * problem is "pay again". It is the same signed token a sale produces, so
149
+ * nothing downstream has to know which kind it is holding.
150
+ */
151
+ async mint(event: LiveEvent, minutes = event.ticketMinutes): Promise<string> {
152
+ const now = Math.floor(Date.now() / 1000);
153
+ const pass = await mintPass({
154
+ secret: this.secretFor(event),
155
+ ref: `comp:${event.id}`,
156
+ expiresAt: now + Math.max(1, Math.floor(minutes)) * 60,
157
+ now,
158
+ });
159
+ return pass.token;
160
+ }
161
+
162
+ /** Whether this token is a ticket this deployment minted for this event. */
163
+ async holds(event: LiveEvent, token: string): Promise<boolean> {
164
+ if (!token || !isTicketed(event)) return false;
165
+ return this.gatewayFor(event).verifyPass(token);
166
+ }
167
+
168
+ /**
169
+ * Sell one. Handed the buyer's request, because the payment proof travels
170
+ * in its headers; answers 402 with the offer, or 200 with the ticket.
171
+ */
172
+ async sell(event: LiveEvent, request: Request): Promise<Response> {
173
+ if (!isTicketed(event)) {
174
+ return Response.json({ error: "this event is free; no ticket is needed" }, { status: 409 });
175
+ }
176
+ if (!this.enabled) {
177
+ return Response.json({ error: "ticket sales are not switched on here" }, { status: 503 });
178
+ }
179
+ return this.gatewayFor(event).sell(request);
180
+ }
181
+ }
182
+
183
+ /** Read a Tickets configuration out of the environment. */
184
+ export function ticketsFromEnv(
185
+ site: string,
186
+ env: NodeJS.ProcessEnv = process.env,
187
+ ): TicketOptions | null {
188
+ const coinpayKey = env["COINPAY_X402_KEY"] ?? "";
189
+ if (!coinpayKey) return null;
190
+ return {
191
+ coinpayKey,
192
+ site,
193
+ ...(env["NIXAMP_TICKET_SECRET"] ? { secret: env["NIXAMP_TICKET_SECRET"] } : {}),
194
+ };
195
+ }
package/src/tokens.ts CHANGED
@@ -28,8 +28,13 @@ export const TOKEN_PREFIX = "nxa_";
28
28
  /** How long a sign-in lasts. Long, because signing in on a television is work. */
29
29
  export const SESSION_DAYS = 90;
30
30
 
31
- /** A session ends; a token a person made for a script does not, unless asked. */
32
- export type TokenKind = "session" | "cli";
31
+ /**
32
+ * A session ends; a token a person made for a script does not, unless asked;
33
+ * and one another site holds on your behalf is an `oauth` token, which is the
34
+ * same thing with a client's name on it so it can be listed and withdrawn as
35
+ * "bittorrented.com" rather than as an anonymous string.
36
+ */
37
+ export type TokenKind = "session" | "cli" | "oauth";
33
38
 
34
39
  export interface TokenRecord {
35
40
  id: string;
@@ -113,7 +118,7 @@ function toRecord(row: Record<string, unknown>): TokenRecord {
113
118
  return {
114
119
  id: String(row["id"] ?? ""),
115
120
  name: String(row["name"] ?? ""),
116
- kind: row["kind"] === "cli" ? "cli" : "session",
121
+ kind: row["kind"] === "cli" || row["kind"] === "oauth" ? (row["kind"] as TokenKind) : "session",
117
122
  createdAt: asTime(row["created_at"]) ?? 0,
118
123
  expiresAt: asTime(row["expires_at"]),
119
124
  lastUsedAt: asTime(row["last_used_at"]),
@@ -233,6 +238,48 @@ export class Tokens {
233
238
  return rows.length > 0;
234
239
  }
235
240
 
241
+ /**
242
+ * One token, by its id, whatever kind it is.
243
+ *
244
+ * `revoke` is scoped to the owner because it answers a person deleting
245
+ * their own token. This one is for the server withdrawing a token it
246
+ * issued -- an access token whose refresh token was just rotated, or the
247
+ * whole family behind a code somebody replayed -- where there is no owner
248
+ * doing the asking.
249
+ */
250
+ async revokeById(id: string): Promise<boolean> {
251
+ await this.ensure();
252
+ const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1 RETURNING id`, [id]);
253
+ return rows.length > 0;
254
+ }
255
+
256
+ /**
257
+ * The record behind a token, without counting it as a use.
258
+ *
259
+ * `verify` is the hot path and stamps last_used_at; this answers the same
260
+ * question for a caller that needs the row itself -- which client holds
261
+ * this, and what it was granted -- and leaves the timestamp alone.
262
+ */
263
+ async inspect(value: string): Promise<(TokenRecord & { account: Account }) | null> {
264
+ const parts = splitToken(value);
265
+ if (parts === null) return null;
266
+ await this.ensure();
267
+ const { rows } = await this.db.query(
268
+ `SELECT id, user_id, email, kind, name, secret_hash, created_at, expires_at, last_used_at
269
+ FROM ${TABLE} WHERE id = $1`,
270
+ [parts.id],
271
+ );
272
+ const row = rows[0];
273
+ if (!row) return null;
274
+ if (!sameHash(String(row["secret_hash"] ?? ""), hashSecret(parts.secret))) return null;
275
+ const expiresAt = asTime(row["expires_at"]);
276
+ if (expiresAt !== null && expiresAt <= this.now()) return null;
277
+ return {
278
+ ...toRecord(row),
279
+ account: { id: String(row["user_id"] ?? ""), email: String(row["email"] ?? "") },
280
+ };
281
+ }
282
+
236
283
  /** Signing out of one place should not sign you out of the build server. */
237
284
  async revokeToken(value: string): Promise<boolean> {
238
285
  const parts = splitToken(value);