nixamp 0.17.0 → 0.18.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 (40) hide show
  1. package/dist/channels.d.ts +54 -1
  2. package/dist/channels.js +160 -23
  3. package/dist/compression/benchmark.d.ts +97 -0
  4. package/dist/compression/benchmark.js +241 -0
  5. package/dist/compression/cli.js +59 -1
  6. package/dist/compression/receiver.d.ts +25 -5
  7. package/dist/compression/receiver.js +54 -5
  8. package/dist/compression/routes.js +7 -2
  9. package/dist/compression/service.d.ts +18 -6
  10. package/dist/compression/service.js +110 -19
  11. package/dist/compression/source.d.ts +17 -0
  12. package/dist/compression/source.js +131 -0
  13. package/dist/links.js +14 -3
  14. package/dist/live-api.js +1 -0
  15. package/dist/live-events.js +11 -3
  16. package/dist/owner.js +10 -0
  17. package/dist/server.js +118 -10
  18. package/dist/share.js +6 -0
  19. package/package.json +1 -1
  20. package/src/channels.ts +180 -22
  21. package/src/compression/benchmark.ts +297 -0
  22. package/src/compression/cli.ts +56 -1
  23. package/src/compression/receiver.ts +68 -7
  24. package/src/compression/routes.ts +7 -2
  25. package/src/compression/service.ts +121 -24
  26. package/src/compression/source.ts +130 -0
  27. package/src/links.ts +12 -3
  28. package/src/live-api.ts +1 -0
  29. package/src/live-events.ts +14 -3
  30. package/src/owner.ts +8 -0
  31. package/src/server.ts +113 -11
  32. package/src/share.ts +5 -0
  33. package/web/dist/assets/{hls-3VKVEQE3-eV54kXE3.js → hls-3VKVEQE3-B2kl0-z1.js} +1 -1
  34. package/web/dist/assets/{index-CurZFzlH.css → index-D1QxpRmE.css} +1 -1
  35. package/web/dist/assets/index-DF6O2leR.js +1 -0
  36. package/web/dist/assets/{mpegts-Buc3Odv6.js → mpegts-4pBNK_Yr.js} +1 -1
  37. package/web/dist/assets/{mpegts-LO6RVLD6-DcDKPB4P.js → mpegts-LO6RVLD6-B4RLM-_e.js} +1 -1
  38. package/web/dist/index.html +15 -10
  39. package/web/dist/sw.js +6 -6
  40. package/web/dist/assets/index-DDzutJ75.js +0 -1
@@ -16,11 +16,14 @@
16
16
  * `nixamp admin` finds its target. JSON goes to stdout and only JSON;
17
17
  * progress and complaints go to stderr; a failure is a nonzero exit.
18
18
  */
19
- import { createWriteStream } from "node:fs";
19
+ import { createWriteStream, mkdirSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
20
21
  import { resolveTarget } from "../admin.ts";
21
22
  import { detectTools } from "../audio.ts";
23
+ import { version as nixampVersion } from "../meta.ts";
22
24
  import { KEY_HEADER } from "../share.ts";
23
25
  import type { Analysis } from "./analyze.ts";
26
+ import { exactnessFailures, fileCorpus, reportMarkdown, reportPasses, runBenchmark, type Sample, syntheticCorpus } from "./benchmark.ts";
24
27
  import { RelayError } from "./envelope.ts";
25
28
  import type { Job } from "./jobs.ts";
26
29
  import { receiveRelay, RelayRefused } from "./receiver.ts";
@@ -36,6 +39,8 @@ const USAGE = `nixamp compression — lossless relay compression: measure it, se
36
39
  nixamp compression off | on the whole server's switch
37
40
  nixamp compression pull --channel ID --from URL [--from-key KEY] [--name NAME]
38
41
  nixamp compression fetch URL --out FILE [--key KEY]
42
+ nixamp compression benchmark [--corpus DIR] [--out DIR] [--levels 1,3,9]
43
+ prove the envelope on a corpus and write a report; no server or ffmpeg needed
39
44
 
40
45
  --url U --key K a server other than the local daemon, as for \`nixamp admin\`
41
46
  --format json JSON on stdout (the default when stdout is not a terminal)
@@ -309,6 +314,53 @@ async function fetchRelay(flags: Flags): Promise<number> {
309
314
  }
310
315
  }
311
316
 
317
+ /**
318
+ * Run the OpenStream benchmark and write a report. Anyone can run it: the
319
+ * default corpus is bytes we generate, no server and no ffmpeg. `--corpus DIR`
320
+ * benchmarks a directory of real files instead; `--out DIR` writes
321
+ * `openstream-report.json` and `.md` there for publishing.
322
+ */
323
+ async function bench(flags: Flags): Promise<number> {
324
+ const dir = flags.named.get("corpus");
325
+ let corpus: Sample[];
326
+ try {
327
+ corpus = dir ? fileCorpus(dir) : syntheticCorpus();
328
+ } catch (error) {
329
+ console.error(`nixamp: cannot read corpus ${dir}: ${(error as Error).message}`);
330
+ return 1;
331
+ }
332
+ if (corpus.length === 0) {
333
+ console.error(`nixamp: no samples${dir ? ` in ${dir}` : ""}`);
334
+ return 1;
335
+ }
336
+ console.error(` Benchmarking ${corpus.length} sample${corpus.length === 1 ? "" : "s"}${dir ? ` from ${dir}` : " (synthetic)"}…`);
337
+ const levels = flags.named.get("levels");
338
+ const report = await runBenchmark(corpus, {
339
+ implementationVersion: nixampVersion(),
340
+ ...(levels ? { zstdLevels: levels.split(",").map((n) => Number(n)).filter((n) => Number.isInteger(n)) } : {}),
341
+ });
342
+ const out = flags.named.get("out");
343
+ if (out) {
344
+ try {
345
+ mkdirSync(out, { recursive: true });
346
+ writeFileSync(join(out, "openstream-report.json"), JSON.stringify(report, null, 2));
347
+ writeFileSync(join(out, "openstream-report.md"), reportMarkdown(report));
348
+ console.error(` Wrote openstream-report.json and .md to ${out}`);
349
+ } catch (error) {
350
+ console.error(`nixamp: cannot write to ${out}: ${(error as Error).message}`);
351
+ return 1;
352
+ }
353
+ }
354
+ if (wantsJson(flags)) console.log(JSON.stringify(report, null, 2));
355
+ else console.log(reportMarkdown(report));
356
+ if (!reportPasses(report)) {
357
+ const bad = exactnessFailures(report).map((f) => `${f.sample}/${f.mode}${f.level ? `L${f.level}` : ""}`).join(", ");
358
+ console.error(`nixamp: these did not restore byte-for-byte, this build must not ship: ${bad}`);
359
+ return 1;
360
+ }
361
+ return 0;
362
+ }
363
+
312
364
  export async function compression(argv: string[]): Promise<number> {
313
365
  const flags = parse(argv);
314
366
  const verb = flags.positional[0];
@@ -329,6 +381,9 @@ export async function compression(argv: string[]): Promise<number> {
329
381
  return await pull(flags);
330
382
  case "fetch":
331
383
  return await fetchRelay(flags);
384
+ case "benchmark":
385
+ case "bench":
386
+ return await bench(flags);
332
387
  default:
333
388
  console.error(USAGE);
334
389
  return verb === undefined || verb === "help" || flags.named.has("help") ? 0 : 2;
@@ -10,13 +10,78 @@
10
10
  * which is surfaced as an error naming the reason, never as a stream of
11
11
  * something else.
12
12
  */
13
- import { MEDIA_TYPE, type Mode, RelayError, type StreamHeader } from "./envelope.ts";
13
+ import { type Boundary, MEDIA_TYPE, type Mode, RelayError, type StreamHeader } from "./envelope.ts";
14
14
  import { RelayDecoder } from "./relay.ts";
15
15
 
16
16
  export const CODECS_HEADER = "x-nixamp-stream-codecs";
17
17
  export const KIND_HEADER = "x-nixamp-kind";
18
+ export const BOUNDARY_HEADER = "x-nixamp-boundary";
19
+ /** What the media inside is, as JSON: video, audio, container, duration. */
20
+ export const MEDIA_HEADER = "x-nixamp-codecs";
18
21
  export const KEY_HEADER = "x-nixamp-key";
19
22
 
23
+ export interface Media {
24
+ video: string;
25
+ audio: string;
26
+ container: string;
27
+ duration?: number;
28
+ }
29
+
30
+ /** What a server said it would send, read off its response headers. */
31
+ export interface Accepted {
32
+ codecs: string;
33
+ kind: "audio" | "video" | "";
34
+ boundary: Boundary | "";
35
+ media: Media | null;
36
+ }
37
+
38
+ function acceptedFrom(headers: Headers): Accepted {
39
+ const kindSaid = headers.get(KIND_HEADER);
40
+ const boundarySaid = headers.get(BOUNDARY_HEADER);
41
+ let media: Media | null = null;
42
+ try {
43
+ const raw = JSON.parse(headers.get(MEDIA_HEADER) ?? "null") as Partial<Media> | null;
44
+ if (raw && typeof raw.video === "string" && typeof raw.audio === "string" && typeof raw.container === "string") {
45
+ media = { video: raw.video, audio: raw.audio, container: raw.container };
46
+ if (typeof raw.duration === "number") media.duration = raw.duration;
47
+ }
48
+ } catch {
49
+ // Not JSON: no media description, which the receiver copes with.
50
+ }
51
+ return {
52
+ codecs: headers.get(CODECS_HEADER) ?? "",
53
+ kind: kindSaid === "audio" || kindSaid === "video" ? kindSaid : "",
54
+ boundary: boundarySaid === "source" || boundarySaid === "channel" ? boundarySaid : "",
55
+ media,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Ask a relay what it would send, without taking it: the negotiation
61
+ * headers come back on the response and the body is cancelled at once.
62
+ * Costs one short connection; a receiver needs to know the boundary and
63
+ * the media before it can decide how to carry the stream.
64
+ */
65
+ export async function probeRelay(url: string, key: string | null, fetchImpl?: typeof fetch): Promise<Accepted> {
66
+ const headers: Record<string, string> = { accept: MEDIA_TYPE, [CODECS_HEADER]: "stored,zstd,ts-zstd" };
67
+ if (key) headers[KEY_HEADER] = key;
68
+ const response = await (fetchImpl ?? fetch)(url, { headers });
69
+ const type = response.headers.get("content-type") ?? "";
70
+ if (response.status !== 200 || !type.startsWith(MEDIA_TYPE)) {
71
+ let reason = `${response.status}`;
72
+ try {
73
+ const body = (await response.json()) as { error?: string };
74
+ if (typeof body.error === "string") reason = body.error;
75
+ } catch {
76
+ // Not JSON; the status is the message.
77
+ }
78
+ throw new RelayRefused(response.status, reason);
79
+ }
80
+ const accepted = acceptedFrom(response.headers);
81
+ await response.body?.cancel().catch(() => undefined);
82
+ return accepted;
83
+ }
84
+
20
85
  export interface ReceiveOptions {
21
86
  url: string;
22
87
  key: string | null;
@@ -24,7 +89,7 @@ export interface ReceiveOptions {
24
89
  modes?: Mode[];
25
90
  maxFrameBytes?: number;
26
91
  /** The server said yes: what it will compress with, and what the channel carries. Before any byte. */
27
- onStart?: (accepted: { codecs: string; kind: "audio" | "video" | "" }) => void;
92
+ onStart?: (accepted: Accepted) => void;
28
93
  /** The stream header arrived: the generation this is. */
29
94
  onHeader?: (header: StreamHeader) => void;
30
95
  onBytes: (bytes: Buffer) => void | Promise<void>;
@@ -74,11 +139,7 @@ export async function receiveRelay(options: ReceiveOptions): Promise<ReceiveResu
74
139
  throw new RelayRefused(response.status, reason);
75
140
  }
76
141
  if (!response.body) throw new RelayRefused(response.status, "no body");
77
- const kindSaid = response.headers.get(KIND_HEADER);
78
- options.onStart?.({
79
- codecs: response.headers.get(CODECS_HEADER) ?? "",
80
- kind: kindSaid === "audio" || kindSaid === "video" ? kindSaid : "",
81
- });
142
+ options.onStart?.(acceptedFrom(response.headers));
82
143
  const decoder = new RelayDecoder({
83
144
  modes,
84
145
  ...(options.maxFrameBytes !== undefined ? { maxFrameBytes: options.maxFrameBytes } : {}),
@@ -12,7 +12,7 @@
12
12
  import { createReadStream } from "node:fs";
13
13
  import type { IncomingMessage, ServerResponse } from "node:http";
14
14
  import { MEDIA_TYPE, parseModes } from "./envelope.ts";
15
- import { CODECS_HEADER, KIND_HEADER } from "./receiver.ts";
15
+ import { BOUNDARY_HEADER, CODECS_HEADER, KIND_HEADER, MEDIA_HEADER } from "./receiver.ts";
16
16
  import type { CompressionService } from "./service.ts";
17
17
 
18
18
  export interface RouteContext {
@@ -140,6 +140,11 @@ export async function handleChannelCompression(
140
140
  "content-encoding": "identity",
141
141
  [CODECS_HEADER]: answer.codecs.join(","),
142
142
  [KIND_HEADER]: answer.kind,
143
+ // Which bytes these are, and what is inside them: a receiver of the
144
+ // source boundary hands them to its own ffmpeg and needs to know
145
+ // what ffmpeg will find, without a probe of its own.
146
+ [BOUNDARY_HEADER]: answer.boundary,
147
+ ...(answer.media ? { [MEDIA_HEADER]: JSON.stringify(answer.media) } : {}),
143
148
  "x-nixamp-generation": String(answer.generation),
144
149
  });
145
150
  const leave = (): void => answer.session.leave();
@@ -160,7 +165,7 @@ export async function handleChannelCompression(
160
165
  }
161
166
  const key = typeof body?.["key"] === "string" ? body["key"] : null;
162
167
  const name = typeof body?.["name"] === "string" ? body["name"] : id;
163
- const started = service.pull(id, from, key, name);
168
+ const started = await service.pull(id, from, key, name);
164
169
  if (!started.ok) {
165
170
  json(response, started.status, { error: started.error });
166
171
  return true;
@@ -8,16 +8,17 @@
8
8
  * translates flags into the routes. There is no second copy of the rules.
9
9
  */
10
10
  import { statSync } from "node:fs";
11
- import { codecsOf } from "../audio.ts";
12
- import { type Channel, type Channels, GIVE_UP, REDIAL } from "../channels.ts";
11
+ import { codecsOf, videoArgs } from "../audio.ts";
12
+ import { type Channel, type ChannelInfo, type Channels, GIVE_UP, type PullThrough, REDIAL } from "../channels.ts";
13
13
  import { type Analysis, analyzeSample, SAMPLE_MAX_BYTES, SAMPLE_MAX_SECONDS } from "./analyze.ts";
14
14
  import { Pool } from "./codec.ts";
15
- import { type Mode, RelayError } from "./envelope.ts";
15
+ import { type Boundary, type Mode, RelayError } from "./envelope.ts";
16
16
  import { AnalysisJobs, type Job } from "./jobs.ts";
17
17
  import { ChannelMetrics, type ChannelMetricsSnapshot } from "./metrics.ts";
18
18
  import { type ChannelPolicy, type LosslessPolicy, variantOf } from "./policy.ts";
19
- import { receiveRelay, RelayRefused } from "./receiver.ts";
19
+ import { type Accepted, probeRelay, receiveRelay, RelayRefused } from "./receiver.ts";
20
20
  import { RelayEncoder, type RelayListener, type RelaySession } from "./relay.ts";
21
+ import { relayThrough, sourceThrough, unreadable } from "./source.ts";
21
22
  import { type GlobalSettings, PolicyStore } from "./store.ts";
22
23
  import { type Prepared, StaticCache } from "./static.ts";
23
24
 
@@ -101,7 +102,16 @@ export interface ChannelStatus {
101
102
  }
102
103
 
103
104
  export type RelayAnswer =
104
- | { ok: true; session: RelaySession; codecs: Mode[]; generation: number; kind: "audio" | "video" | "" }
105
+ | {
106
+ ok: true;
107
+ session: RelaySession;
108
+ codecs: Mode[];
109
+ generation: number;
110
+ kind: "audio" | "video" | "";
111
+ /** Which bytes the stream carries, and what is inside them. */
112
+ boundary: Boundary;
113
+ media: ChannelInfo["codecs"] | null;
114
+ }
105
115
  | { ok: false; status: 404 | 406 | 409 | 503; code: string; error: string };
106
116
 
107
117
  interface Running {
@@ -110,7 +120,17 @@ interface Running {
110
120
  variant: string;
111
121
  }
112
122
 
113
- /** An incoming relay: this server as the receiver, dialling again when it drops. */
123
+ /**
124
+ * An incoming relay: this server as the receiver.
125
+ *
126
+ * Two shapes, decided by what the sender says it carries. A channel-boundary
127
+ * relay is the sender's finished output, so it is fed straight into a
128
+ * channel here and dialled again by this class when it drops. A
129
+ * source-boundary relay is the original transport stream, so it is handed
130
+ * to a channel's own ffmpeg as a source read through us -- which plays it
131
+ * exactly as the sender would have, lets it be relayed on again, and puts
132
+ * the redialling in the channel where it already lives.
133
+ */
114
134
  class Incoming {
115
135
  generation = 0;
116
136
  reconnects = 0;
@@ -223,8 +243,24 @@ export class CompressionService {
223
243
  this.statics = options.cacheDir
224
244
  ? new StaticCache(options.cacheDir, { pool: this.pool, ...(options.maxCacheBytes !== undefined ? { maxBytes: options.maxCacheBytes } : {}) })
225
245
  : null;
246
+ // Whether a pulled source is read here rather than by ffmpeg, decided at
247
+ // every dial: only when the channel's policy asks for the source boundary
248
+ // and the source is one we can read exactly (a plain transport stream).
249
+ // A channel we brought in from another nixamp's source-boundary relay is
250
+ // itself read through us, so it can be relayed on.
251
+ this.channels.setThrough((info, from, input, audio) => {
252
+ const relayed = this.relaySource.get(info.id);
253
+ if (relayed) return relayThrough(relayed.from, relayed.key);
254
+ const policy = this.store.get(info.id).losslessCompression;
255
+ if (policy.mode === "off" || policy.boundary !== "source") return null;
256
+ if (unreadable(info, from, input, audio) !== null) return null;
257
+ return sourceThrough(info.source ?? "");
258
+ });
226
259
  }
227
260
 
261
+ /** Incoming source-boundary relays, so their channel is read through us. */
262
+ private readonly relaySource = new Map<string, { from: string; key: string | null }>();
263
+
228
264
  private get channels(): Channels {
229
265
  return this.options.channels;
230
266
  }
@@ -239,8 +275,15 @@ export class CompressionService {
239
275
  lossless.mode = "off";
240
276
  reason = "compression is off for the whole server";
241
277
  } else if (lossless.mode !== "off" && lossless.boundary === "source") {
242
- lossless.mode = "off";
243
- reason = "original source bytes are unavailable: this server's sources are read by ffmpeg, and only the channel boundary can be relayed";
278
+ // The source boundary is available only for a source we can read here.
279
+ // A live channel that is not read through us cannot offer it; a channel
280
+ // that is not on yet is given the benefit of the doubt until it is.
281
+ const info = this.channels.info(id);
282
+ const why = info ? unreadable(info, 0, [], "") : null;
283
+ if (why !== null) {
284
+ lossless.mode = "off";
285
+ reason = `original source bytes are unavailable: ${why}`;
286
+ }
244
287
  }
245
288
  return {
246
289
  losslessCompression: lossless,
@@ -333,7 +376,9 @@ export class CompressionService {
333
376
  }
334
377
  // A receiver that cannot undo the transform gets a variant without it.
335
378
  const variantPolicy: LosslessPolicy = { ...policy, tsAware: policy.tsAware && codecs.includes("ts-zstd") };
336
- const variant = variantOf(variantPolicy);
379
+ // The boundary is part of the variant: a source relay and a channel
380
+ // relay are two different streams, and two different compressors.
381
+ const variant = `${policy.boundary}:${variantOf(variantPolicy)}`;
337
382
  let running = this.running.get(id);
338
383
  if (running && running.variant !== variant) {
339
384
  // One compressor per channel. A second variant would be a second
@@ -341,17 +386,32 @@ export class CompressionService {
341
386
  return { ok: false, status: 409, code: "VARIANT_IN_USE", error: "this channel is already being relayed under a different codec set; try again when that relay ends" };
342
387
  }
343
388
  if (!running) {
344
- const started = this.startEncoder(id, variantPolicy, variant);
345
- if (!started) return { ok: false, status: 503, code: "CHANNEL_GONE", error: "the channel ended before the relay could start" };
389
+ const started = this.startEncoder(id, variantPolicy, variant, policy.boundary);
390
+ if (!started) {
391
+ return started === null
392
+ ? { ok: false, status: 503, code: "CHANNEL_GONE", error: "the channel ended before the relay could start" }
393
+ : { ok: false, status: 409, code: "SOURCE_BOUNDARY_UNAVAILABLE", error: "the source is not being read through this server, so its original bytes are not available to relay" };
394
+ }
346
395
  running = started;
347
396
  }
348
- // The opening bytes and the join point, in the same tick.
349
- const preface = this.channels.opening(id);
397
+ // For the channel boundary, the opening bytes are the preface. The source
398
+ // boundary has none: a joining demuxer re-syncs on the next PAT and
399
+ // keyframe, exactly as it does when a live source is joined.
400
+ const preface = policy.boundary === "source" ? [] : this.channels.opening(id);
350
401
  const session = running.encoder.join(listener, preface);
351
- return { ok: true, session, codecs, generation: running.encoder.generation, kind: this.channels.kindOf(id) ?? "" };
402
+ return {
403
+ ok: true,
404
+ session,
405
+ codecs,
406
+ generation: running.encoder.generation,
407
+ kind: this.channels.kindOf(id) ?? "",
408
+ boundary: policy.boundary,
409
+ media: this.channels.info(id)?.codecs ?? null,
410
+ };
352
411
  }
353
412
 
354
- private startEncoder(id: string, policy: LosslessPolicy, variant: string): Running | null {
413
+ /** Null: the channel ended. False: the source boundary was asked for but is not being read through us. */
414
+ private startEncoder(id: string, policy: LosslessPolicy, variant: string, boundary: Boundary): Running | null | false {
355
415
  this.generation = (this.generation + 1) % 0xffff_ffff;
356
416
  const metrics = this.metrics.get(id) ?? new ChannelMetrics();
357
417
  this.metrics.set(id, metrics);
@@ -361,7 +421,7 @@ export class CompressionService {
361
421
  policy,
362
422
  pool: this.pool,
363
423
  metrics,
364
- boundary: "channel",
424
+ boundary,
365
425
  onAbort: () => {
366
426
  if (record && this.running.get(id) === record) {
367
427
  this.running.delete(id);
@@ -378,22 +438,58 @@ export class CompressionService {
378
438
  }
379
439
  },
380
440
  });
381
- const detach = this.channels.listen(id, {
382
- write: (chunk) => encoder.write(chunk),
383
- end: () => encoder.end(),
384
- pending: () => metrics.queueBytes,
385
- });
386
- if (detach === null) return null;
441
+ const face = {
442
+ write: (chunk: Buffer): boolean => encoder.write(chunk),
443
+ end: (): void => encoder.end(),
444
+ pending: (): number => metrics.queueBytes,
445
+ };
446
+ // The source boundary taps the source's own bytes; the channel boundary
447
+ // is one more ordinary listener on the channel's output.
448
+ const detach = boundary === "source" ? this.channels.tapSource(id, face) : this.channels.listen(id, face);
449
+ if (detach === null) return boundary === "source" ? false : null;
387
450
  encoder.attach();
388
451
  record = { encoder, detach, variant };
389
452
  this.running.set(id, record);
390
453
  return record;
391
454
  }
392
455
 
393
- /** Start listening to another nixamp's channel as one of ours. */
394
- pull(id: string, from: string, key: string | null, name: string): { ok: true } | { ok: false; status: 409 | 400; error: string } {
456
+ /**
457
+ * Start listening to another nixamp's channel as one of ours. Probes the
458
+ * relay first to learn its boundary: a channel-boundary relay is decoded
459
+ * and its bytes are the channel's output directly; a source-boundary relay
460
+ * is the original transport stream, so it is read through ffmpeg here
461
+ * exactly as a pulled source would be, and can be relayed on again.
462
+ */
463
+ async pull(id: string, from: string, key: string | null, name: string): Promise<{ ok: true } | { ok: false; status: 409 | 400 | 502; error: string }> {
395
464
  if (!/^https?:\/\//i.test(from)) return { ok: false, status: 400, error: "the relay address must be http or https" };
396
465
  if (this.channels.has(id) || this.incoming.has(id)) return { ok: false, status: 409, error: `channel "${id}" is already on` };
466
+ let accepted: Accepted;
467
+ try {
468
+ accepted = await probeRelay(from, key);
469
+ } catch (error) {
470
+ const message = error instanceof RelayRefused ? `the relay refused: ${error.message}` : (error as Error).message;
471
+ return { ok: false, status: 502, error: message };
472
+ }
473
+ if (accepted.boundary === "source") {
474
+ // The upstream sends the original transport stream. Read it through
475
+ // ffmpeg here: the through-provider sees this registration and hands
476
+ // ffmpeg the decoded bytes. It can then be relayed on at either
477
+ // boundary, since its source now passes through this process.
478
+ const media = accepted.media;
479
+ const kind: "audio" | "video" = accepted.kind === "audio" ? "audio" : "video";
480
+ const encode = kind === "video"
481
+ ? videoArgs(media ?? { video: "h264", audio: "aac", container: "mpegts" })
482
+ : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
483
+ this.relaySource.set(id, { from, key });
484
+ // A placeholder source string: the through-provider supplies the bytes,
485
+ // so this is only what a listener would never see and a restart reads.
486
+ const channel = this.channels.pull(id, name, from, encode, kind, true, undefined, [], "", { live: true, position: 0 }, media ?? undefined);
487
+ if (!channel) {
488
+ this.relaySource.delete(id);
489
+ return { ok: false, status: 409, error: `channel "${id}" is already on` };
490
+ }
491
+ return { ok: true };
492
+ }
397
493
  const inbound = new Incoming(id, from, key, name, this.channels, this.options.onEvent ?? (() => undefined), (gone) => this.incoming.delete(gone));
398
494
  this.incoming.set(id, inbound);
399
495
  inbound.start();
@@ -402,6 +498,7 @@ export class CompressionService {
402
498
 
403
499
  /** Stop an incoming relay, and the channel it feeds. */
404
500
  stopPull(id: string): boolean {
501
+ if (this.relaySource.delete(id)) return this.channels.stop(id);
405
502
  const inbound = this.incoming.get(id);
406
503
  if (!inbound) return false;
407
504
  inbound.stop();
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Reading a source ourselves, so its bytes exist here to be relayed.
3
+ *
4
+ * ffmpeg normally dials a channel's source and the original bytes never
5
+ * pass through this process. For a source-boundary relay they have to.
6
+ * This is the narrow set of sources that can be read here and piped to
7
+ * ffmpeg without changing how they play: a transport stream from a plain
8
+ * http(s) URL or a file, from its beginning, with no special request
9
+ * headers and no separate sound file. Anything else is left to ffmpeg,
10
+ * and a relay that asks for its source boundary is told why not.
11
+ *
12
+ * The second half is the receiving end of a source-boundary relay: the
13
+ * decoded original bytes, made into a source ffmpeg can be handed the
14
+ * same way, so a relayed transport stream plays here exactly as the
15
+ * original would have, and can be relayed on again.
16
+ */
17
+ import { createReadStream, statSync } from "node:fs";
18
+ import type { ChannelInfo, PullThrough } from "../channels.ts";
19
+ import { receiveRelay } from "./receiver.ts";
20
+
21
+ /**
22
+ * ffmpeg input flags that carry per-request authentication a plain fetch
23
+ * would not send: a user agent, a referer, cookies, arbitrary headers. A
24
+ * source that needs any of these is ffmpeg's to dial. Transport demux
25
+ * tuning (`-probesize`, `-analyzeduration`, `-fflags`) is not on this list:
26
+ * it applies to a piped read too and is no reason to refuse the tee.
27
+ */
28
+ const HEADER_FLAGS = new Set(["-headers", "-user_agent", "-user-agent", "-referer", "-cookies", "-icy", "-http_proxy", "-http_persistent"]);
29
+
30
+ /** The reason a source cannot be read here, or null when it can. */
31
+ export function unreadable(info: ChannelInfo, from: number, input: string[], audio: string): string | null {
32
+ const source = info.source ?? "";
33
+ if (info.via !== "pull" || source === "") return "only a source this server pulls can be read here";
34
+ const container = info.codecs?.container ?? "";
35
+ if (!container.split(",").includes("mpegts")) return `the source is ${container || "of unknown container"}, not a transport stream`;
36
+ if (input.some((arg) => HEADER_FLAGS.has(arg))) return "the source needs request headers ffmpeg sends and nixamp does not";
37
+ if (audio !== "") return "the source keeps its sound in a second file";
38
+ if (from > 0) return "a film picked up mid-way cannot be read from its start";
39
+ if (/^https?:\/\//i.test(source)) return null;
40
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(source)) return "only http(s) URLs and files can be read here";
41
+ try {
42
+ if (!statSync(source).isFile()) return "the source is not a file";
43
+ } catch {
44
+ return "the source file cannot be read";
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** Open a plain URL or a file as a stream of bytes, pulled shut by the signal. */
50
+ export async function openSource(source: string, signal: AbortSignal): Promise<AsyncIterable<Uint8Array>> {
51
+ if (/^https?:\/\//i.test(source)) {
52
+ const response = await fetch(source, { signal, redirect: "follow", headers: { "user-agent": "nixamp" } });
53
+ if (!response.ok || !response.body) throw new Error(`the source answered ${response.status}`);
54
+ return response.body as unknown as AsyncIterable<Uint8Array>;
55
+ }
56
+ const stream = createReadStream(source, { highWaterMark: 256 * 1024 });
57
+ signal.addEventListener("abort", () => stream.destroy(), { once: true });
58
+ return stream;
59
+ }
60
+
61
+ /** A transport-stream source read by us, for `Channels.setThrough`. */
62
+ export function sourceThrough(source: string): PullThrough {
63
+ return { format: "mpegts", open: (signal) => openSource(source, signal) };
64
+ }
65
+
66
+ /** How many decoded bytes may wait for ffmpeg before the relay is asked to pause. */
67
+ const RELAY_QUEUE = 8 * 1024 * 1024;
68
+
69
+ /**
70
+ * A relay's decoded bytes as an async iterable, at the pace ffmpeg takes
71
+ * them. The decoder is asked to wait when too much is queued, which holds
72
+ * the socket read, which is backpressure all the way to the sender's
73
+ * per-listener queue. A clean end ends the iterable; a broken stream
74
+ * throws, and the channel's own redial dials again.
75
+ */
76
+ export async function* relayBytes(url: string, key: string | null, signal: AbortSignal): AsyncGenerator<Buffer> {
77
+ const queue: Buffer[] = [];
78
+ let queued = 0;
79
+ let wake: (() => void) | null = null;
80
+ let room: (() => void) | null = null;
81
+ let done = false;
82
+ let failure: Error | null = null;
83
+ void receiveRelay({
84
+ url,
85
+ key,
86
+ signal,
87
+ onBytes: async (bytes) => {
88
+ queue.push(bytes);
89
+ queued += bytes.length;
90
+ wake?.();
91
+ wake = null;
92
+ if (queued > RELAY_QUEUE) await new Promise<void>((resume) => { room = resume; });
93
+ },
94
+ }).then(
95
+ () => {
96
+ done = true;
97
+ wake?.();
98
+ },
99
+ (error: Error) => {
100
+ failure = error;
101
+ done = true;
102
+ wake?.();
103
+ },
104
+ );
105
+ for (;;) {
106
+ const next = queue.shift();
107
+ if (next) {
108
+ queued -= next.length;
109
+ // Captured into a local: `room` is only ever assigned inside a callback,
110
+ // which the compiler cannot see, so it would narrow the field to null.
111
+ const resume = room as (() => void) | null;
112
+ if (resume && queued <= RELAY_QUEUE / 2) {
113
+ resume();
114
+ room = null;
115
+ }
116
+ yield next;
117
+ continue;
118
+ }
119
+ if (done) {
120
+ if (failure) throw failure;
121
+ return;
122
+ }
123
+ await new Promise<void>((resume) => { wake = resume; });
124
+ }
125
+ }
126
+
127
+ /** A source-boundary relay from another nixamp, as a source ffmpeg reads through us. */
128
+ export function relayThrough(url: string, key: string | null): PullThrough {
129
+ return { format: "mpegts", open: async (signal) => relayBytes(url, key, signal) };
130
+ }
package/src/links.ts CHANGED
@@ -177,6 +177,13 @@ export function parseResolved(json: unknown, page: string): ResolvedLink | null
177
177
  }
178
178
  }
179
179
  if (media === "") return null;
180
+ // A link that was the media all along -- an IPTV feed at /channel/906,
181
+ // with no extension to say so -- is answered by yt-dlp's generic
182
+ // extractor with wherever the feed redirected to, which for a panel is a
183
+ // second host and a token minted for that one request. ffmpeg follows a
184
+ // redirect itself, so the link is kept as pasted and every redial gets a
185
+ // fresh token instead of a 403 an hour in.
186
+ if (record["direct"] === true && parts.length === 0 && /^https?:\/\//i.test(page)) media = page;
180
187
  const title = typeof record["title"] === "string" && record["title"].trim() !== ""
181
188
  ? record["title"].trim().slice(0, 200)
182
189
  : fileNameOf(page);
@@ -306,9 +313,11 @@ export async function resolveLink(
306
313
  options: { cookies?: string; timeoutMs?: number; format?: string } = {},
307
314
  ): Promise<ResolvedLink | { error: string }> {
308
315
  if (isDirectMedia(url)) return directLink(url);
309
- if (!ytdlp || ytdlp.length === 0) {
310
- return { error: "this server has no yt-dlp, so it can only play a direct link to a file or a stream" };
311
- }
316
+ // Without yt-dlp, a link is taken as the media it may well be: an IPTV
317
+ // feed has no extension and no page behind it, and ffprobe -- asked next,
318
+ // before anything goes on the air -- tells a stream from a web page in a
319
+ // second. A page it cannot read is refused there, by name.
320
+ if (!ytdlp || ytdlp.length === 0) return directLink(url);
312
321
  const [command, ...prefix] = ytdlp as [string, ...string[]];
313
322
  return new Promise((done) => {
314
323
  let out = "";
package/src/live-api.ts CHANGED
@@ -628,6 +628,7 @@ export async function handleLiveApi(
628
628
  json(response, error.status, { error: error.message });
629
629
  return true;
630
630
  }
631
+ console.error(`live api ${request.method} ${path}:`, error instanceof Error ? error.message : error);
631
632
  json(response, 500, { error: "the live event service failed" });
632
633
  return true;
633
634
  }
@@ -173,19 +173,22 @@ const EVENT_SCHEMA = `
173
173
  ON live_events (visibility, status, starts_at, updated_at DESC);
174
174
  CREATE INDEX IF NOT EXISTS live_events_owner
175
175
  ON live_events (owner_id, updated_at DESC);
176
- CREATE INDEX IF NOT EXISTS live_events_kind
177
- ON live_events (kind, status, starts_at);
178
176
 
179
177
  -- The repo has no migration runner, so a table that already exists is
180
178
  -- brought forward here. Every statement is idempotent, and the CHECKs are
181
179
  -- replaced by name rather than added twice: the originals were unnamed, so
182
180
  -- the ones carrying the old status list are found by what they say.
181
+ -- The columns come before any index on them: nixamp.com had live_events
182
+ -- from before 0.17.0, and an index on kind ahead of ADD COLUMN kind failed
183
+ -- there while every fresh database (tests, CI) sailed through.
183
184
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'talk';
184
185
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS doors_open_at TIMESTAMPTZ;
185
186
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_price_cents INTEGER NOT NULL DEFAULT 0;
186
187
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_currency TEXT NOT NULL DEFAULT 'USD';
187
188
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_minutes INTEGER NOT NULL DEFAULT 1440;
188
189
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS pay_to TEXT;
190
+ CREATE INDEX IF NOT EXISTS live_events_kind
191
+ ON live_events (kind, status, starts_at);
189
192
 
190
193
  DO $$
191
194
  DECLARE stale record;
@@ -511,7 +514,15 @@ export class LiveEvents {
511
514
  constructor(private readonly db: Queryable) {}
512
515
 
513
516
  private async ensure(): Promise<void> {
514
- this.ready ??= this.db.query(EVENT_SCHEMA).then(() => undefined);
517
+ // A failed schema run is not remembered: the next request tries again
518
+ // rather than answering 500 until the process restarts.
519
+ this.ready ??= this.db.query(EVENT_SCHEMA).then(
520
+ () => undefined,
521
+ (error: unknown) => {
522
+ this.ready = null;
523
+ throw error;
524
+ },
525
+ );
515
526
  await this.ready;
516
527
  }
517
528