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
@@ -8,7 +8,7 @@
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.js";
11
+ import { codecsOf, videoArgs } from "../audio.js";
12
12
  import { GIVE_UP, REDIAL } from "../channels.js";
13
13
  import { analyzeSample, SAMPLE_MAX_BYTES, SAMPLE_MAX_SECONDS } from "./analyze.js";
14
14
  import { Pool } from "./codec.js";
@@ -16,8 +16,9 @@ import { RelayError } from "./envelope.js";
16
16
  import { AnalysisJobs } from "./jobs.js";
17
17
  import { ChannelMetrics } from "./metrics.js";
18
18
  import { variantOf } from "./policy.js";
19
- import { receiveRelay, RelayRefused } from "./receiver.js";
19
+ import { probeRelay, receiveRelay, RelayRefused } from "./receiver.js";
20
20
  import { RelayEncoder } from "./relay.js";
21
+ import { relayThrough, sourceThrough, unreadable } from "./source.js";
21
22
  import { PolicyStore } from "./store.js";
22
23
  import { StaticCache } from "./static.js";
23
24
  /** The pseudo-channel whose policy governs static file representations. */
@@ -59,7 +60,17 @@ export async function analyzeFile(path, options = {}) {
59
60
  ...(ffprobe ? { ffprobeVersion: ffprobe.join(" ") } : {}),
60
61
  });
61
62
  }
62
- /** An incoming relay: this server as the receiver, dialling again when it drops. */
63
+ /**
64
+ * An incoming relay: this server as the receiver.
65
+ *
66
+ * Two shapes, decided by what the sender says it carries. A channel-boundary
67
+ * relay is the sender's finished output, so it is fed straight into a
68
+ * channel here and dialled again by this class when it drops. A
69
+ * source-boundary relay is the original transport stream, so it is handed
70
+ * to a channel's own ffmpeg as a source read through us -- which plays it
71
+ * exactly as the sender would have, lets it be relayed on again, and puts
72
+ * the redialling in the channel where it already lives.
73
+ */
63
74
  class Incoming {
64
75
  id;
65
76
  from;
@@ -182,7 +193,25 @@ export class CompressionService {
182
193
  this.statics = options.cacheDir
183
194
  ? new StaticCache(options.cacheDir, { pool: this.pool, ...(options.maxCacheBytes !== undefined ? { maxBytes: options.maxCacheBytes } : {}) })
184
195
  : null;
196
+ // Whether a pulled source is read here rather than by ffmpeg, decided at
197
+ // every dial: only when the channel's policy asks for the source boundary
198
+ // and the source is one we can read exactly (a plain transport stream).
199
+ // A channel we brought in from another nixamp's source-boundary relay is
200
+ // itself read through us, so it can be relayed on.
201
+ this.channels.setThrough((info, from, input, audio) => {
202
+ const relayed = this.relaySource.get(info.id);
203
+ if (relayed)
204
+ return relayThrough(relayed.from, relayed.key);
205
+ const policy = this.store.get(info.id).losslessCompression;
206
+ if (policy.mode === "off" || policy.boundary !== "source")
207
+ return null;
208
+ if (unreadable(info, from, input, audio) !== null)
209
+ return null;
210
+ return sourceThrough(info.source ?? "");
211
+ });
185
212
  }
213
+ /** Incoming source-boundary relays, so their channel is read through us. */
214
+ relaySource = new Map();
186
215
  get channels() {
187
216
  return this.options.channels;
188
217
  }
@@ -197,8 +226,15 @@ export class CompressionService {
197
226
  reason = "compression is off for the whole server";
198
227
  }
199
228
  else if (lossless.mode !== "off" && lossless.boundary === "source") {
200
- lossless.mode = "off";
201
- reason = "original source bytes are unavailable: this server's sources are read by ffmpeg, and only the channel boundary can be relayed";
229
+ // The source boundary is available only for a source we can read here.
230
+ // A live channel that is not read through us cannot offer it; a channel
231
+ // that is not on yet is given the benefit of the doubt until it is.
232
+ const info = this.channels.info(id);
233
+ const why = info ? unreadable(info, 0, [], "") : null;
234
+ if (why !== null) {
235
+ lossless.mode = "off";
236
+ reason = `original source bytes are unavailable: ${why}`;
237
+ }
202
238
  }
203
239
  return {
204
240
  losslessCompression: lossless,
@@ -290,7 +326,9 @@ export class CompressionService {
290
326
  }
291
327
  // A receiver that cannot undo the transform gets a variant without it.
292
328
  const variantPolicy = { ...policy, tsAware: policy.tsAware && codecs.includes("ts-zstd") };
293
- const variant = variantOf(variantPolicy);
329
+ // The boundary is part of the variant: a source relay and a channel
330
+ // relay are two different streams, and two different compressors.
331
+ const variant = `${policy.boundary}:${variantOf(variantPolicy)}`;
294
332
  let running = this.running.get(id);
295
333
  if (running && running.variant !== variant) {
296
334
  // One compressor per channel. A second variant would be a second
@@ -298,17 +336,31 @@ export class CompressionService {
298
336
  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" };
299
337
  }
300
338
  if (!running) {
301
- const started = this.startEncoder(id, variantPolicy, variant);
302
- if (!started)
303
- return { ok: false, status: 503, code: "CHANNEL_GONE", error: "the channel ended before the relay could start" };
339
+ const started = this.startEncoder(id, variantPolicy, variant, policy.boundary);
340
+ if (!started) {
341
+ return started === null
342
+ ? { ok: false, status: 503, code: "CHANNEL_GONE", error: "the channel ended before the relay could start" }
343
+ : { 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" };
344
+ }
304
345
  running = started;
305
346
  }
306
- // The opening bytes and the join point, in the same tick.
307
- const preface = this.channels.opening(id);
347
+ // For the channel boundary, the opening bytes are the preface. The source
348
+ // boundary has none: a joining demuxer re-syncs on the next PAT and
349
+ // keyframe, exactly as it does when a live source is joined.
350
+ const preface = policy.boundary === "source" ? [] : this.channels.opening(id);
308
351
  const session = running.encoder.join(listener, preface);
309
- return { ok: true, session, codecs, generation: running.encoder.generation, kind: this.channels.kindOf(id) ?? "" };
352
+ return {
353
+ ok: true,
354
+ session,
355
+ codecs,
356
+ generation: running.encoder.generation,
357
+ kind: this.channels.kindOf(id) ?? "",
358
+ boundary: policy.boundary,
359
+ media: this.channels.info(id)?.codecs ?? null,
360
+ };
310
361
  }
311
- startEncoder(id, policy, variant) {
362
+ /** Null: the channel ended. False: the source boundary was asked for but is not being read through us. */
363
+ startEncoder(id, policy, variant, boundary) {
312
364
  this.generation = (this.generation + 1) % 0xffff_ffff;
313
365
  const metrics = this.metrics.get(id) ?? new ChannelMetrics();
314
366
  this.metrics.set(id, metrics);
@@ -318,7 +370,7 @@ export class CompressionService {
318
370
  policy,
319
371
  pool: this.pool,
320
372
  metrics,
321
- boundary: "channel",
373
+ boundary,
322
374
  onAbort: () => {
323
375
  if (record && this.running.get(id) === record) {
324
376
  this.running.delete(id);
@@ -335,24 +387,61 @@ export class CompressionService {
335
387
  }
336
388
  },
337
389
  });
338
- const detach = this.channels.listen(id, {
390
+ const face = {
339
391
  write: (chunk) => encoder.write(chunk),
340
392
  end: () => encoder.end(),
341
393
  pending: () => metrics.queueBytes,
342
- });
394
+ };
395
+ // The source boundary taps the source's own bytes; the channel boundary
396
+ // is one more ordinary listener on the channel's output.
397
+ const detach = boundary === "source" ? this.channels.tapSource(id, face) : this.channels.listen(id, face);
343
398
  if (detach === null)
344
- return null;
399
+ return boundary === "source" ? false : null;
345
400
  encoder.attach();
346
401
  record = { encoder, detach, variant };
347
402
  this.running.set(id, record);
348
403
  return record;
349
404
  }
350
- /** Start listening to another nixamp's channel as one of ours. */
351
- pull(id, from, key, name) {
405
+ /**
406
+ * Start listening to another nixamp's channel as one of ours. Probes the
407
+ * relay first to learn its boundary: a channel-boundary relay is decoded
408
+ * and its bytes are the channel's output directly; a source-boundary relay
409
+ * is the original transport stream, so it is read through ffmpeg here
410
+ * exactly as a pulled source would be, and can be relayed on again.
411
+ */
412
+ async pull(id, from, key, name) {
352
413
  if (!/^https?:\/\//i.test(from))
353
414
  return { ok: false, status: 400, error: "the relay address must be http or https" };
354
415
  if (this.channels.has(id) || this.incoming.has(id))
355
416
  return { ok: false, status: 409, error: `channel "${id}" is already on` };
417
+ let accepted;
418
+ try {
419
+ accepted = await probeRelay(from, key);
420
+ }
421
+ catch (error) {
422
+ const message = error instanceof RelayRefused ? `the relay refused: ${error.message}` : error.message;
423
+ return { ok: false, status: 502, error: message };
424
+ }
425
+ if (accepted.boundary === "source") {
426
+ // The upstream sends the original transport stream. Read it through
427
+ // ffmpeg here: the through-provider sees this registration and hands
428
+ // ffmpeg the decoded bytes. It can then be relayed on at either
429
+ // boundary, since its source now passes through this process.
430
+ const media = accepted.media;
431
+ const kind = accepted.kind === "audio" ? "audio" : "video";
432
+ const encode = kind === "video"
433
+ ? videoArgs(media ?? { video: "h264", audio: "aac", container: "mpegts" })
434
+ : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
435
+ this.relaySource.set(id, { from, key });
436
+ // A placeholder source string: the through-provider supplies the bytes,
437
+ // so this is only what a listener would never see and a restart reads.
438
+ const channel = this.channels.pull(id, name, from, encode, kind, true, undefined, [], "", { live: true, position: 0 }, media ?? undefined);
439
+ if (!channel) {
440
+ this.relaySource.delete(id);
441
+ return { ok: false, status: 409, error: `channel "${id}" is already on` };
442
+ }
443
+ return { ok: true };
444
+ }
356
445
  const inbound = new Incoming(id, from, key, name, this.channels, this.options.onEvent ?? (() => undefined), (gone) => this.incoming.delete(gone));
357
446
  this.incoming.set(id, inbound);
358
447
  inbound.start();
@@ -360,6 +449,8 @@ export class CompressionService {
360
449
  }
361
450
  /** Stop an incoming relay, and the channel it feeds. */
362
451
  stopPull(id) {
452
+ if (this.relaySource.delete(id))
453
+ return this.channels.stop(id);
363
454
  const inbound = this.incoming.get(id);
364
455
  if (!inbound)
365
456
  return false;
@@ -0,0 +1,17 @@
1
+ import type { ChannelInfo, PullThrough } from "../channels.ts";
2
+ /** The reason a source cannot be read here, or null when it can. */
3
+ export declare function unreadable(info: ChannelInfo, from: number, input: string[], audio: string): string | null;
4
+ /** Open a plain URL or a file as a stream of bytes, pulled shut by the signal. */
5
+ export declare function openSource(source: string, signal: AbortSignal): Promise<AsyncIterable<Uint8Array>>;
6
+ /** A transport-stream source read by us, for `Channels.setThrough`. */
7
+ export declare function sourceThrough(source: string): PullThrough;
8
+ /**
9
+ * A relay's decoded bytes as an async iterable, at the pace ffmpeg takes
10
+ * them. The decoder is asked to wait when too much is queued, which holds
11
+ * the socket read, which is backpressure all the way to the sender's
12
+ * per-listener queue. A clean end ends the iterable; a broken stream
13
+ * throws, and the channel's own redial dials again.
14
+ */
15
+ export declare function relayBytes(url: string, key: string | null, signal: AbortSignal): AsyncGenerator<Buffer>;
16
+ /** A source-boundary relay from another nixamp, as a source ffmpeg reads through us. */
17
+ export declare function relayThrough(url: string, key: string | null): PullThrough;
@@ -0,0 +1,131 @@
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 { receiveRelay } from "./receiver.js";
19
+ /**
20
+ * ffmpeg input flags that carry per-request authentication a plain fetch
21
+ * would not send: a user agent, a referer, cookies, arbitrary headers. A
22
+ * source that needs any of these is ffmpeg's to dial. Transport demux
23
+ * tuning (`-probesize`, `-analyzeduration`, `-fflags`) is not on this list:
24
+ * it applies to a piped read too and is no reason to refuse the tee.
25
+ */
26
+ const HEADER_FLAGS = new Set(["-headers", "-user_agent", "-user-agent", "-referer", "-cookies", "-icy", "-http_proxy", "-http_persistent"]);
27
+ /** The reason a source cannot be read here, or null when it can. */
28
+ export function unreadable(info, from, input, audio) {
29
+ const source = info.source ?? "";
30
+ if (info.via !== "pull" || source === "")
31
+ return "only a source this server pulls can be read here";
32
+ const container = info.codecs?.container ?? "";
33
+ if (!container.split(",").includes("mpegts"))
34
+ return `the source is ${container || "of unknown container"}, not a transport stream`;
35
+ if (input.some((arg) => HEADER_FLAGS.has(arg)))
36
+ return "the source needs request headers ffmpeg sends and nixamp does not";
37
+ if (audio !== "")
38
+ return "the source keeps its sound in a second file";
39
+ if (from > 0)
40
+ return "a film picked up mid-way cannot be read from its start";
41
+ if (/^https?:\/\//i.test(source))
42
+ return null;
43
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(source))
44
+ return "only http(s) URLs and files can be read here";
45
+ try {
46
+ if (!statSync(source).isFile())
47
+ return "the source is not a file";
48
+ }
49
+ catch {
50
+ return "the source file cannot be read";
51
+ }
52
+ return null;
53
+ }
54
+ /** Open a plain URL or a file as a stream of bytes, pulled shut by the signal. */
55
+ export async function openSource(source, signal) {
56
+ if (/^https?:\/\//i.test(source)) {
57
+ const response = await fetch(source, { signal, redirect: "follow", headers: { "user-agent": "nixamp" } });
58
+ if (!response.ok || !response.body)
59
+ throw new Error(`the source answered ${response.status}`);
60
+ return response.body;
61
+ }
62
+ const stream = createReadStream(source, { highWaterMark: 256 * 1024 });
63
+ signal.addEventListener("abort", () => stream.destroy(), { once: true });
64
+ return stream;
65
+ }
66
+ /** A transport-stream source read by us, for `Channels.setThrough`. */
67
+ export function sourceThrough(source) {
68
+ return { format: "mpegts", open: (signal) => openSource(source, signal) };
69
+ }
70
+ /** How many decoded bytes may wait for ffmpeg before the relay is asked to pause. */
71
+ const RELAY_QUEUE = 8 * 1024 * 1024;
72
+ /**
73
+ * A relay's decoded bytes as an async iterable, at the pace ffmpeg takes
74
+ * them. The decoder is asked to wait when too much is queued, which holds
75
+ * the socket read, which is backpressure all the way to the sender's
76
+ * per-listener queue. A clean end ends the iterable; a broken stream
77
+ * throws, and the channel's own redial dials again.
78
+ */
79
+ export async function* relayBytes(url, key, signal) {
80
+ const queue = [];
81
+ let queued = 0;
82
+ let wake = null;
83
+ let room = null;
84
+ let done = false;
85
+ let failure = null;
86
+ void receiveRelay({
87
+ url,
88
+ key,
89
+ signal,
90
+ onBytes: async (bytes) => {
91
+ queue.push(bytes);
92
+ queued += bytes.length;
93
+ wake?.();
94
+ wake = null;
95
+ if (queued > RELAY_QUEUE)
96
+ await new Promise((resume) => { room = resume; });
97
+ },
98
+ }).then(() => {
99
+ done = true;
100
+ wake?.();
101
+ }, (error) => {
102
+ failure = error;
103
+ done = true;
104
+ wake?.();
105
+ });
106
+ for (;;) {
107
+ const next = queue.shift();
108
+ if (next) {
109
+ queued -= next.length;
110
+ // Captured into a local: `room` is only ever assigned inside a callback,
111
+ // which the compiler cannot see, so it would narrow the field to null.
112
+ const resume = room;
113
+ if (resume && queued <= RELAY_QUEUE / 2) {
114
+ resume();
115
+ room = null;
116
+ }
117
+ yield next;
118
+ continue;
119
+ }
120
+ if (done) {
121
+ if (failure)
122
+ throw failure;
123
+ return;
124
+ }
125
+ await new Promise((resume) => { wake = resume; });
126
+ }
127
+ }
128
+ /** A source-boundary relay from another nixamp, as a source ffmpeg reads through us. */
129
+ export function relayThrough(url, key) {
130
+ return { format: "mpegts", open: async (signal) => relayBytes(url, key, signal) };
131
+ }
package/dist/links.js CHANGED
@@ -146,6 +146,14 @@ export function parseResolved(json, page) {
146
146
  }
147
147
  if (media === "")
148
148
  return null;
149
+ // A link that was the media all along -- an IPTV feed at /channel/906,
150
+ // with no extension to say so -- is answered by yt-dlp's generic
151
+ // extractor with wherever the feed redirected to, which for a panel is a
152
+ // second host and a token minted for that one request. ffmpeg follows a
153
+ // redirect itself, so the link is kept as pasted and every redial gets a
154
+ // fresh token instead of a 403 an hour in.
155
+ if (record["direct"] === true && parts.length === 0 && /^https?:\/\//i.test(page))
156
+ media = page;
149
157
  const title = typeof record["title"] === "string" && record["title"].trim() !== ""
150
158
  ? record["title"].trim().slice(0, 200)
151
159
  : fileNameOf(page);
@@ -269,9 +277,12 @@ export const RESOLVE_TIMEOUT_MS = 60_000;
269
277
  export async function resolveLink(ytdlp, url, options = {}) {
270
278
  if (isDirectMedia(url))
271
279
  return directLink(url);
272
- if (!ytdlp || ytdlp.length === 0) {
273
- return { error: "this server has no yt-dlp, so it can only play a direct link to a file or a stream" };
274
- }
280
+ // Without yt-dlp, a link is taken as the media it may well be: an IPTV
281
+ // feed has no extension and no page behind it, and ffprobe -- asked next,
282
+ // before anything goes on the air -- tells a stream from a web page in a
283
+ // second. A page it cannot read is refused there, by name.
284
+ if (!ytdlp || ytdlp.length === 0)
285
+ return directLink(url);
275
286
  const [command, ...prefix] = ytdlp;
276
287
  return new Promise((done) => {
277
288
  let out = "";
package/dist/live-api.js CHANGED
@@ -563,6 +563,7 @@ export async function handleLiveApi(request, response, url, options) {
563
563
  json(response, error.status, { error: error.message });
564
564
  return true;
565
565
  }
566
+ console.error(`live api ${request.method} ${path}:`, error instanceof Error ? error.message : error);
566
567
  json(response, 500, { error: "the live event service failed" });
567
568
  return true;
568
569
  }
@@ -71,19 +71,22 @@ const EVENT_SCHEMA = `
71
71
  ON live_events (visibility, status, starts_at, updated_at DESC);
72
72
  CREATE INDEX IF NOT EXISTS live_events_owner
73
73
  ON live_events (owner_id, updated_at DESC);
74
- CREATE INDEX IF NOT EXISTS live_events_kind
75
- ON live_events (kind, status, starts_at);
76
74
 
77
75
  -- The repo has no migration runner, so a table that already exists is
78
76
  -- brought forward here. Every statement is idempotent, and the CHECKs are
79
77
  -- replaced by name rather than added twice: the originals were unnamed, so
80
78
  -- the ones carrying the old status list are found by what they say.
79
+ -- The columns come before any index on them: nixamp.com had live_events
80
+ -- from before 0.17.0, and an index on kind ahead of ADD COLUMN kind failed
81
+ -- there while every fresh database (tests, CI) sailed through.
81
82
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'talk';
82
83
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS doors_open_at TIMESTAMPTZ;
83
84
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_price_cents INTEGER NOT NULL DEFAULT 0;
84
85
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_currency TEXT NOT NULL DEFAULT 'USD';
85
86
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_minutes INTEGER NOT NULL DEFAULT 1440;
86
87
  ALTER TABLE live_events ADD COLUMN IF NOT EXISTS pay_to TEXT;
88
+ CREATE INDEX IF NOT EXISTS live_events_kind
89
+ ON live_events (kind, status, starts_at);
87
90
 
88
91
  DO $$
89
92
  DECLARE stale record;
@@ -402,7 +405,12 @@ export class LiveEvents {
402
405
  this.db = db;
403
406
  }
404
407
  async ensure() {
405
- this.ready ??= this.db.query(EVENT_SCHEMA).then(() => undefined);
408
+ // A failed schema run is not remembered: the next request tries again
409
+ // rather than answering 500 until the process restarts.
410
+ this.ready ??= this.db.query(EVENT_SCHEMA).then(() => undefined, (error) => {
411
+ this.ready = null;
412
+ throw error;
413
+ });
406
414
  await this.ready;
407
415
  }
408
416
  async create(ownerId, input) {
package/dist/owner.js CHANGED
@@ -136,8 +136,18 @@ export function needsMember(path, method = "GET") {
136
136
  return true;
137
137
  if (method === "POST" && /^\/api\/channels\/[^/]+\/keep$/.test(path))
138
138
  return true;
139
+ // A link -- a YouTube page, an IPTV feed, a file somewhere -- is the one
140
+ // thing a member can go live with that needs nothing on the server first,
141
+ // and it was the one thing they could not. Going live with one is the same
142
+ // act as going live with a file here: counted, marked theirs, and capped
143
+ // by the handler. Fetching a link to keep (download) stays the owner's.
144
+ if (method === "POST" && path === "/api/links/play")
145
+ return true;
139
146
  // Only their own; the handler checks whose it is.
140
147
  if (method === "DELETE" && /^\/api\/channels\/[^/]+$/.test(path))
141
148
  return true;
149
+ // Renaming what they put on, likewise.
150
+ if (method === "PATCH" && /^\/api\/channels\/[^/]+$/.test(path))
151
+ return true;
142
152
  return false;
143
153
  }
package/dist/server.js CHANGED
@@ -896,7 +896,10 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
896
896
  // the timestamps a recording cut mid-stream does not carry. Ahead of the
897
897
  // caller's own input arguments, which are headers for the address itself.
898
898
  const opening = [...transportInputArgs(source, codecs.container), ...input];
899
- const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0 });
899
+ const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0 },
900
+ // Known before the first dial: whether the source is a transport stream
901
+ // decides whether it can be read here for a source-boundary relay.
902
+ assumed ? undefined : codecs);
900
903
  if (channel && !assumed)
901
904
  channel.info.codecs = codecs;
902
905
  // What comes out, as opposed to what went in. An H.265 source copied
@@ -926,18 +929,25 @@ export function cookiesFile() {
926
929
  return "";
927
930
  }
928
931
  }
929
- /** What the page is told about a link it asked to play. */
930
- function shownLink(channelId, link) {
932
+ /**
933
+ * What the page is told about a link it asked to play.
934
+ *
935
+ * Whether it is live is the channel's answer where there is one: yt-dlp
936
+ * says nothing about an IPTV feed's liveness, and a feed told to the page
937
+ * as a film got a Download button and a seek bar for a stream with no end.
938
+ */
939
+ function shownLink(channelId, link, channel) {
940
+ const live = channel?.live ?? link.live;
931
941
  return {
932
942
  kind: "live",
933
943
  channel: channelId,
934
- name: link.title,
935
- live: link.live,
944
+ name: channel?.name ?? link.title,
945
+ live,
936
946
  video: link.video,
937
947
  duration: link.duration,
938
948
  extractor: link.extractor,
939
949
  // A live has no whole to keep; a bare file can be fetched by the browser itself.
940
- download: !link.live && link.extractor !== "direct",
950
+ download: !live && link.extractor !== "direct",
941
951
  };
942
952
  }
943
953
  export function liveOnes(engine) {
@@ -2646,6 +2656,15 @@ export function createHandler(engine, options) {
2646
2656
  response.end(JSON.stringify({ match }));
2647
2657
  return;
2648
2658
  }
2659
+ /*
2660
+ * A link, fetched by this server and carried as a channel.
2661
+ *
2662
+ * Played: started on demand, stopped a minute after the last viewer
2663
+ * leaves. Live (`{ live: true }`, and always for a member): kept,
2664
+ * remembered across a restart, listed in the directory with a room
2665
+ * code, and -- for a member -- marked theirs and counted against what
2666
+ * they may have on at once, exactly like a file from the library.
2667
+ */
2649
2668
  if (path === "/api/links/play" && request.method === "POST") {
2650
2669
  let body = {};
2651
2670
  try {
@@ -2666,11 +2685,54 @@ export function createHandler(engine, options) {
2666
2685
  }
2667
2686
  const channelId = linkChannelId(link);
2668
2687
  const known = links.get(link);
2688
+ // A member's is always a live: a decoder started on somebody else's
2689
+ // machine is theirs to own, not a thing left running anonymously.
2690
+ const goLive = body.live === true || liveBy !== "";
2691
+ /**
2692
+ * On the air and kept, as going live means; for a member, theirs.
2693
+ * Answers "" or a refusal. `fresh` is a channel this very request
2694
+ * started, which is nobody's yet and was counted before it was.
2695
+ */
2696
+ const putOnTheAir = (fresh) => {
2697
+ const channels = options.channels;
2698
+ const info = channels.info(channelId);
2699
+ if (!info)
2700
+ return "";
2701
+ if (liveBy && !info.startedBy) {
2702
+ // Somebody's already, and not started on demand: the owner's.
2703
+ if (!fresh && !channels.isEphemeral(channelId))
2704
+ return "that stream is the owner's";
2705
+ if (!fresh) {
2706
+ const refusal = memberLiveRefusal(channels, liveBy);
2707
+ if (refusal)
2708
+ return refusal;
2709
+ }
2710
+ info.startedBy = liveBy;
2711
+ }
2712
+ channels.keep(channelId);
2713
+ options.rememberChannels?.(rememberedNow(channels));
2714
+ return "";
2715
+ };
2669
2716
  if (options.channels.has(channelId) && known) {
2670
- json(response, 200, shownLink(channelId, known));
2717
+ if (goLive) {
2718
+ const refusal = putOnTheAir(false);
2719
+ if (refusal) {
2720
+ json(response, refusal.endsWith("owner's") ? 403 : 429, { error: refusal });
2721
+ return;
2722
+ }
2723
+ void options.live?.announce?.();
2724
+ }
2725
+ json(response, 200, shownLink(channelId, known, options.channels.info(channelId)));
2671
2726
  return;
2672
2727
  }
2673
- if (!options.channels.has(channelId) && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
2728
+ if (!options.channels.has(channelId) && liveBy) {
2729
+ const refusal = memberLiveRefusal(options.channels, liveBy);
2730
+ if (refusal) {
2731
+ json(response, 429, { error: refusal });
2732
+ return;
2733
+ }
2734
+ }
2735
+ if (!options.channels.has(channelId) && !goLive && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
2674
2736
  json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
2675
2737
  return;
2676
2738
  }
@@ -2699,12 +2761,23 @@ export function createHandler(engine, options) {
2699
2761
  json(response, 409, { error: "that link is already starting" });
2700
2762
  return;
2701
2763
  }
2702
- options.channels.ephemeral(channelId);
2764
+ if (goLive)
2765
+ putOnTheAir(true);
2766
+ else
2767
+ options.channels.ephemeral(channelId);
2703
2768
  // Told to the directory now, so the room code arrives with the
2704
2769
  // channel rather than at the next heartbeat, ninety seconds on.
2705
2770
  void options.live?.announce?.();
2706
2771
  }
2707
- json(response, 200, shownLink(channelId, resolved));
2772
+ else if (goLive) {
2773
+ const refusal = putOnTheAir(false);
2774
+ if (refusal) {
2775
+ json(response, refusal.endsWith("owner's") ? 403 : 429, { error: refusal });
2776
+ return;
2777
+ }
2778
+ void options.live?.announce?.();
2779
+ }
2780
+ json(response, 200, shownLink(channelId, resolved, options.channels.info(channelId)));
2708
2781
  return;
2709
2782
  }
2710
2783
  // The whole thing, to keep. The server fetches it through yt-dlp and
@@ -2970,6 +3043,41 @@ export function createHandler(engine, options) {
2970
3043
  response.on("close", leave);
2971
3044
  return;
2972
3045
  }
3046
+ /*
3047
+ * Rename what is on the air. A link goes on as "906" or whatever the
3048
+ * file was called, and the directory lists that; the person who put it
3049
+ * on knows what it is. The owner renames anything; a member, their own.
3050
+ */
3051
+ if (action === undefined && request.method === "PATCH") {
3052
+ const info = channels.info(id);
3053
+ if (!info) {
3054
+ json(response, 404, { error: "nothing is playing on that channel" });
3055
+ return;
3056
+ }
3057
+ if (liveBy && info.startedBy !== liveBy) {
3058
+ json(response, 403, { error: "that stream is not yours to rename" });
3059
+ return;
3060
+ }
3061
+ let body = {};
3062
+ try {
3063
+ body = JSON.parse(await readBody(request));
3064
+ }
3065
+ catch {
3066
+ json(response, 400, { error: "bad JSON" });
3067
+ return;
3068
+ }
3069
+ const name = typeof body.name === "string" ? body.name.replace(/[\r\n\t]+/g, " ").trim().slice(0, 120) : "";
3070
+ if (name === "") {
3071
+ json(response, 400, { error: "a name is needed" });
3072
+ return;
3073
+ }
3074
+ info.name = name;
3075
+ options.rememberChannels?.(rememberedNow(channels));
3076
+ // Listed under the new name now, not at the next heartbeat.
3077
+ void options.live?.announce?.();
3078
+ json(response, 200, { ok: true, channel: id, name });
3079
+ return;
3080
+ }
2973
3081
  if (action === undefined && request.method === "DELETE") {
2974
3082
  // A member takes off what they put on, and nothing else.
2975
3083
  if (liveBy && channels.info(id)?.startedBy !== liveBy) {
package/dist/share.js CHANGED
@@ -335,6 +335,12 @@ export function allowedForListening(path) {
335
335
  // Having the server fetch a link is a decoder and a download on somebody
336
336
  // else's machine: the control link's to ask for, not the listen link's. A
337
337
  // viewer's link plays in their own browser.
338
+ // Putting a link on the air is the exception: a member reaches a server
339
+ // from nixamp.com with its listen link and their session, and the
340
+ // administering gate behind this one decides whether that session is a
341
+ // member's. A listen key alone still gets a 403 there.
342
+ if (path === "/api/links/play")
343
+ return true;
338
344
  if (path === "/api/links" || path.startsWith("/api/links/"))
339
345
  return false;
340
346
  return true;