nixamp 0.9.7 → 0.9.9

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.
@@ -173,6 +173,8 @@ export declare class Channel {
173
173
  /** Write to everyone, and drop anybody whose socket has gone. */
174
174
  private send;
175
175
  listen(listener: Listener): () => void;
176
+ /** Stay up with nobody watching: no longer on demand. */
177
+ keep(): void;
176
178
  /** Nobody is watching an on-demand channel: give it a minute, then stop. */
177
179
  private idleOut;
178
180
  close(): void;
@@ -218,6 +220,16 @@ export declare class Channels {
218
220
  pulled(id: string): boolean;
219
221
  /** Mark a channel as on demand: it stops itself a minute after its last viewer leaves. */
220
222
  ephemeral(id: string): void;
223
+ /**
224
+ * The opposite: a channel that stays up with nobody watching.
225
+ *
226
+ * Going live with something from a catalog turns the on-demand channel it
227
+ * was being watched on into a broadcast -- listed, shareable, and still
228
+ * there when the person who started it closes their tab.
229
+ */
230
+ keep(id: string): boolean;
231
+ /** Whether a channel stops itself when its last viewer leaves. */
232
+ isEphemeral(id: string): boolean;
221
233
  /** How many on-demand channels are up, for a ceiling on decoders. */
222
234
  get ephemeralCount(): number;
223
235
  /** What a listener should be told this channel is. */
package/dist/channels.js CHANGED
@@ -437,6 +437,13 @@ export class Channel {
437
437
  this.idleOut();
438
438
  };
439
439
  }
440
+ /** Stay up with nobody watching: no longer on demand. */
441
+ keep() {
442
+ this.ephemeral = false;
443
+ if (this.idle)
444
+ clearTimeout(this.idle);
445
+ this.idle = null;
446
+ }
440
447
  /** Nobody is watching an on-demand channel: give it a minute, then stop. */
441
448
  idleOut() {
442
449
  if (this.idle)
@@ -588,6 +595,24 @@ export class Channels {
588
595
  if (channel.listeners.size === 0)
589
596
  channel.listen({ write: () => true, end: () => undefined })();
590
597
  }
598
+ /**
599
+ * The opposite: a channel that stays up with nobody watching.
600
+ *
601
+ * Going live with something from a catalog turns the on-demand channel it
602
+ * was being watched on into a broadcast -- listed, shareable, and still
603
+ * there when the person who started it closes their tab.
604
+ */
605
+ keep(id) {
606
+ const channel = this.open.get(id);
607
+ if (!channel)
608
+ return false;
609
+ channel.keep();
610
+ return true;
611
+ }
612
+ /** Whether a channel stops itself when its last viewer leaves. */
613
+ isEphemeral(id) {
614
+ return this.open.get(id)?.ephemeral === true;
615
+ }
591
616
  /** How many on-demand channels are up, for a ceiling on decoders. */
592
617
  get ephemeralCount() {
593
618
  let total = 0;
@@ -99,6 +99,23 @@ export declare function roomCodeFrom(entered: unknown): string;
99
99
  export declare function pacificTime(at: number): string;
100
100
  /** How a code is read back: one digit at a time, because 482917 is not a number. */
101
101
  export declare function spokenCode(code: string): string;
102
+ /**
103
+ * A track's name as it should be read out.
104
+ *
105
+ * What is playing is a filename more often than a title: "02 - ...And Justice
106
+ * For All.mp3" read aloud is "zero two dash dot dot dot", and the extension is
107
+ * a noise the caller does not need. The track number and extension go; what
108
+ * is left is close enough to a title to say.
109
+ */
110
+ export declare function spokenTitle(nowPlaying: string): string;
111
+ /**
112
+ * How many people are in a room, said to the one who just walked in.
113
+ *
114
+ * The count includes them: "there are 3 people in here" is what you say to the
115
+ * third person, and "you are the first one here" is what you say to the first,
116
+ * who would otherwise be told there is one person in an empty room.
117
+ */
118
+ export declare function peopleHere(callers: number): string;
102
119
  export interface TelnyxEvent {
103
120
  event_type?: string;
104
121
  payload?: Record<string, unknown>;
@@ -119,6 +136,10 @@ export declare class PartyLine {
119
136
  private readonly legFrom;
120
137
  /** Legs that heard "press 1", and which stream they would be reminded about. */
121
138
  private readonly pendingReminder;
139
+ /** How many times each leg has been asked for a code, so it is not forever. */
140
+ private readonly asks;
141
+ /** Legs whose voice failed once, which hear the plain one from then on. */
142
+ private readonly plainVoice;
122
143
  /** Who to text when a stream returns, by stream code. */
123
144
  private readonly reminders;
124
145
  /**
@@ -216,9 +237,24 @@ export declare class PartyLine {
216
237
  }): Promise<number>;
217
238
  /** How many numbers are waiting to hear that a code is live. */
218
239
  waitingOn(code: string): number;
219
- /** Put a leg into a room, making the conference if it is the first one there. */
240
+ /**
241
+ * Put a leg into a room, making the conference if it is the first one there.
242
+ *
243
+ * `welcome` is what the caller hears once they are in, before the count of
244
+ * who else is; a stream's room names the stream, an ordinary room reads its
245
+ * code back.
246
+ */
220
247
  private join;
221
248
  private enter;
249
+ /**
250
+ * Say hello to somebody who just joined, and only to them.
251
+ *
252
+ * Spoken into the conference rather than at the leg, because the leg is in
253
+ * the conference now and a speak on it is what the join interrupts. Telnyx
254
+ * addresses conference speech to particular participants, so the others in
255
+ * the room do not hear every arrival welcomed.
256
+ */
257
+ private greet;
222
258
  /**
223
259
  * How many people are on the phone for a stream.
224
260
  *
@@ -239,6 +275,8 @@ export declare class PartyLine {
239
275
  */
240
276
  private room;
241
277
  private get voice();
278
+ /** The voice for this leg: the good one, unless it has already failed them. */
279
+ private voiceFor;
242
280
  private get maxParticipants();
243
281
  /** One call-control command. True when Telnyx accepted it. */
244
282
  private command;
package/dist/partyline.js CHANGED
@@ -40,6 +40,33 @@ const TELNYX_API = "https://api.telnyx.com/v2";
40
40
  * configuration rather than a constant for exactly that reason.
41
41
  */
42
42
  const DEFAULT_VOICE = "Telnyx.KokoroTTS.af";
43
+ /**
44
+ * The voice a leg falls back to when the good one fails.
45
+ *
46
+ * Telnyx's Kokoro voice answered a prompt with a 500 once (2026-09-09), and
47
+ * what the caller got was a gather with no speech in it, ended at once with
48
+ * no digits, and asked again -- a line that "just repeats itself", silently,
49
+ * every ninety seconds. The plain Telnyx voice is older and worse and has not
50
+ * been seen to fail, which is the quality that matters on the second try.
51
+ */
52
+ const FALLBACK_VOICE = "female";
53
+ /**
54
+ * How long a caller has between digits before the code is treated as done.
55
+ *
56
+ * Telnyx's default is five seconds and did not fire: a caller who keyed five
57
+ * digits (one was lost in the keypad tone over the prompt) waited ten seconds
58
+ * in silence and hung up. Set explicitly so the partial code comes back to us
59
+ * quickly and we can say how many digits we got.
60
+ */
61
+ const INTER_DIGIT_MS = 4000;
62
+ /**
63
+ * How many times a caller is asked for a code before being let go.
64
+ *
65
+ * A gather that ends with nothing three times is a caller who cannot or will
66
+ * not key a code -- or a voice that is not being heard at all. Asking a
67
+ * fourth time is the loop that was reported; saying goodbye is not.
68
+ */
69
+ const MAX_ASKS = 3;
43
70
  /** How long a signed webhook stays acceptable. Telnyx's own SDKs use five minutes. */
44
71
  const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
45
72
  /** A conference Telnyx will discard on its own, so we stop trusting ours first. */
@@ -92,6 +119,35 @@ export function pacificTime(at) {
92
119
  export function spokenCode(code) {
93
120
  return code.split("").join(", ");
94
121
  }
122
+ /**
123
+ * A track's name as it should be read out.
124
+ *
125
+ * What is playing is a filename more often than a title: "02 - ...And Justice
126
+ * For All.mp3" read aloud is "zero two dash dot dot dot", and the extension is
127
+ * a noise the caller does not need. The track number and extension go; what
128
+ * is left is close enough to a title to say.
129
+ */
130
+ export function spokenTitle(nowPlaying) {
131
+ return nowPlaying
132
+ .replace(/\.[a-z0-9]{2,4}$/i, "")
133
+ .replace(/^\s*\d{1,3}\s*[-._]\s*/, "")
134
+ .replace(/^[\s.]+/, "")
135
+ .trim();
136
+ }
137
+ /**
138
+ * How many people are in a room, said to the one who just walked in.
139
+ *
140
+ * The count includes them: "there are 3 people in here" is what you say to the
141
+ * third person, and "you are the first one here" is what you say to the first,
142
+ * who would otherwise be told there is one person in an empty room.
143
+ */
144
+ export function peopleHere(callers) {
145
+ if (callers <= 1)
146
+ return "You are the first one here. Say hello when somebody joins.";
147
+ if (callers === 2)
148
+ return "There is one other person in here. Say hello.";
149
+ return `There are ${callers} people in here. Say hello.`;
150
+ }
95
151
  /**
96
152
  * Telnyx signs `${timestamp}|${body}` with ed25519 and sends both back in
97
153
  * headers. Node will not take a bare 32-byte key, so it is wrapped in the
@@ -135,6 +191,10 @@ export class PartyLine {
135
191
  legFrom = new Map();
136
192
  /** Legs that heard "press 1", and which stream they would be reminded about. */
137
193
  pendingReminder = new Map();
194
+ /** How many times each leg has been asked for a code, so it is not forever. */
195
+ asks = new Map();
196
+ /** Legs whose voice failed once, which hear the plain one from then on. */
197
+ plainVoice = new Set();
138
198
  /** Who to text when a stream returns, by stream code. */
139
199
  reminders = new Map();
140
200
  /**
@@ -244,8 +304,20 @@ export class PartyLine {
244
304
  await this.ask(leg);
245
305
  return;
246
306
  }
307
+ if (type === "call.speak.failed") {
308
+ // The voice, not the caller, failed. Everything this leg hears from now
309
+ // on is in the plain voice; the gather this speech belonged to ends on
310
+ // its own and is asked again, audibly this time.
311
+ this.plainVoice.add(leg);
312
+ this.options.onEvent?.(" a prompt could not be spoken; using the plain voice.");
313
+ return;
314
+ }
247
315
  if (type === "call.gather.ended") {
248
316
  const digits = typeof payload["digits"] === "string" ? payload["digits"] : "";
317
+ // A gather that ended because the caller hung up is not an answer, and
318
+ // anything sent to that leg now is a 422 for the log.
319
+ if (payload["status"] === "call_hangup")
320
+ return;
249
321
  // A leg that was just offered a reminder is answering that, not keying a
250
322
  // room code -- the same event carries both, so the question we asked is
251
323
  // what decides how to read it.
@@ -259,9 +331,14 @@ export class PartyLine {
259
331
  if (!code) {
260
332
  // Re-ask rather than guess. Anything that is not six digits is not a
261
333
  // room, and picking the nearest one would be picking a stranger's.
262
- await this.ask(leg, "That is not a six digit code. ");
334
+ // Say what we got: five digits and silence is a caller who thinks
335
+ // the line is broken, and a caller who keyed nothing does not need
336
+ // telling their nothing was not six digits.
337
+ const got = digits.replace(/\D/g, "").length;
338
+ await this.ask(leg, got > 0 ? `I only got ${got} digits. ` : "I did not get a code. ");
263
339
  return;
264
340
  }
341
+ this.asks.delete(leg);
265
342
  // A code that belongs to a stream is answered as a stream. Anything else
266
343
  // is an ordinary room, which is what this line was before.
267
344
  if (await this.stream(leg, code))
@@ -273,6 +350,8 @@ export class PartyLine {
273
350
  this.release(leg);
274
351
  this.legFrom.delete(leg);
275
352
  this.pendingReminder.delete(leg);
353
+ this.asks.delete(leg);
354
+ this.plainVoice.delete(leg);
276
355
  return;
277
356
  }
278
357
  }
@@ -290,16 +369,33 @@ export class PartyLine {
290
369
  * to press anything after; # is there for the ones who do it anyway.
291
370
  */
292
371
  async ask(leg, prefix = "") {
293
- const greeting = this.options.greeting ??
294
- "Welcome to the party line. Enter a six digit room code. Anyone who enters the same code will be on the line with you.";
372
+ const asked = (this.asks.get(leg) ?? 0) + 1;
373
+ this.asks.set(leg, asked);
374
+ if (asked > MAX_ASKS) {
375
+ // Three gathers with no code in them is not a caller who needs a fourth
376
+ // prompt. Whatever is wrong -- their keypad, our voice -- repeating
377
+ // ourselves is the failure that was reported, so this ends instead.
378
+ await this.command(leg, "speak", {
379
+ payload: "I did not get a room code. Goodbye.",
380
+ voice: this.voiceFor(leg),
381
+ });
382
+ await this.command(leg, "hangup", {});
383
+ return;
384
+ }
385
+ // Short, because callers key the code over the prompt and a long one
386
+ // costs digits: a tone pressed as the speech starts was not heard. The
387
+ // first time gets the welcome; a re-ask has already been welcomed.
388
+ const greeting = this.options.greeting ?? "Welcome to the nixamp party line. Enter the six digit room code.";
389
+ const payload = asked === 1 ? `${prefix}${greeting}` : `${prefix}Enter the six digit room code.`;
295
390
  await this.command(leg, "gather_using_speak", {
296
- payload: `${prefix}${greeting}`,
297
- voice: this.voice,
391
+ payload,
392
+ voice: this.voiceFor(leg),
298
393
  valid_digits: "0123456789",
299
394
  minimum_digits: CODE_LENGTH,
300
395
  maximum_digits: CODE_LENGTH,
301
396
  terminating_digit: "#",
302
397
  timeout_millis: 20000,
398
+ inter_digit_timeout_millis: INTER_DIGIT_MS,
303
399
  });
304
400
  }
305
401
  /**
@@ -324,13 +420,13 @@ export class PartyLine {
324
420
  return false;
325
421
  const live = streams.liveByCode(code);
326
422
  if (live !== undefined) {
327
- const what = live.nowPlaying ? ` of ${live.nowPlaying}` : "";
328
- await this.command(leg, "speak", {
329
- payload: `You're on the line for ${live.name}${what}. ` +
330
- "Everyone here is watching it too. Say hello.",
331
- voice: this.voice,
332
- });
333
- await this.join(leg, code);
423
+ // The welcome is said once they are in the room, not before: a speak
424
+ // on the leg followed by the conference join was cut off by the join --
425
+ // Telnyx reported it started and ended in the same millisecond -- and
426
+ // the caller heard twenty seconds of nothing and hung up.
427
+ const title = spokenTitle(live.nowPlaying);
428
+ const what = title ? `, playing ${title}` : "";
429
+ await this.join(leg, code, `Welcome to the live room for ${live.name}${what}. `);
334
430
  return true;
335
431
  }
336
432
  const ended = streams.endedByCode(code);
@@ -345,7 +441,7 @@ export class PartyLine {
345
441
  `The live stream ended at ${pacificTime(ended.endedAt)}. ` +
346
442
  "Call back later when they stream again. " +
347
443
  "Press 1 to get a text message when they do.",
348
- voice: this.voice,
444
+ voice: this.voiceFor(leg),
349
445
  valid_digits: "1",
350
446
  minimum_digits: 1,
351
447
  maximum_digits: 1,
@@ -370,7 +466,7 @@ export class PartyLine {
370
466
  this.options.onEvent?.(` a caller asked to be told when ${code} is live again.`);
371
467
  await this.command(leg, "speak", {
372
468
  payload: "Got it. We will text you when they are live again. Goodbye.",
373
- voice: this.voice,
469
+ voice: this.voiceFor(leg),
374
470
  });
375
471
  await this.command(leg, "hangup", {});
376
472
  }
@@ -408,13 +504,19 @@ export class PartyLine {
408
504
  waitingOn(code) {
409
505
  return this.reminders.get(code)?.size ?? 0;
410
506
  }
411
- /** Put a leg into a room, making the conference if it is the first one there. */
412
- async join(leg, code) {
507
+ /**
508
+ * Put a leg into a room, making the conference if it is the first one there.
509
+ *
510
+ * `welcome` is what the caller hears once they are in, before the count of
511
+ * who else is; a stream's room names the stream, an ordinary room reads its
512
+ * code back.
513
+ */
514
+ async join(leg, code, welcome = `Welcome to room ${spokenCode(code)}. `) {
413
515
  const room = this.room(code);
414
516
  if (room.callers >= this.maxParticipants) {
415
517
  await this.command(leg, "speak", {
416
518
  payload: "That room is full. Goodbye.",
417
- voice: this.voice,
519
+ voice: this.voiceFor(leg),
418
520
  });
419
521
  await this.command(leg, "hangup", {});
420
522
  return;
@@ -427,6 +529,7 @@ export class PartyLine {
427
529
  const joined = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/join`, { call_control_id: leg, start_conference_on_enter: true });
428
530
  if (joined !== null) {
429
531
  this.enter(room, leg);
532
+ await this.greet(room, leg, welcome);
430
533
  return;
431
534
  }
432
535
  // The id was stale in a way the clock did not predict -- an operator
@@ -445,7 +548,7 @@ export class PartyLine {
445
548
  if (typeof id !== "string") {
446
549
  await this.command(leg, "speak", {
447
550
  payload: "Sorry, that room could not be opened. Goodbye.",
448
- voice: this.voice,
551
+ voice: this.voiceFor(leg),
449
552
  });
450
553
  await this.command(leg, "hangup", {});
451
554
  this.legRoom.delete(leg);
@@ -454,6 +557,7 @@ export class PartyLine {
454
557
  room.conferenceId = id;
455
558
  room.startedAt = this.now();
456
559
  this.enter(room, leg);
560
+ await this.greet(room, leg, welcome);
457
561
  }
458
562
  enter(room, leg) {
459
563
  if (room.legs.has(leg))
@@ -462,6 +566,23 @@ export class PartyLine {
462
566
  room.callers = room.legs.size;
463
567
  this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
464
568
  }
569
+ /**
570
+ * Say hello to somebody who just joined, and only to them.
571
+ *
572
+ * Spoken into the conference rather than at the leg, because the leg is in
573
+ * the conference now and a speak on it is what the join interrupts. Telnyx
574
+ * addresses conference speech to particular participants, so the others in
575
+ * the room do not hear every arrival welcomed.
576
+ */
577
+ async greet(room, leg, welcome) {
578
+ if (room.conferenceId === null)
579
+ return;
580
+ await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
581
+ payload: `${welcome}${peopleHere(room.callers)}`,
582
+ voice: this.voiceFor(leg),
583
+ call_control_ids: [leg],
584
+ });
585
+ }
465
586
  /**
466
587
  * How many people are on the phone for a stream.
467
588
  *
@@ -515,6 +636,10 @@ export class PartyLine {
515
636
  get voice() {
516
637
  return this.options.voice ?? DEFAULT_VOICE;
517
638
  }
639
+ /** The voice for this leg: the good one, unless it has already failed them. */
640
+ voiceFor(leg) {
641
+ return this.plainVoice.has(leg) ? FALLBACK_VOICE : this.voice;
642
+ }
518
643
  get maxParticipants() {
519
644
  return this.options.maxParticipants ?? 50;
520
645
  }
package/dist/server.d.ts CHANGED
@@ -485,6 +485,12 @@ export interface HandlerOptions {
485
485
  error?: string;
486
486
  }>;
487
487
  stop: () => Promise<void>;
488
+ /**
489
+ * Tell the directory now rather than at the next heartbeat. A channel
490
+ * that just went on the air should be in the list before the person who
491
+ * put it there has looked, and a heartbeat is ninety seconds.
492
+ */
493
+ announce?: () => Promise<void>;
488
494
  };
489
495
  /**
490
496
  * Where OBS should point, one entry per stream this server will accept.
package/dist/server.js CHANGED
@@ -2221,6 +2221,34 @@ export function createHandler(engine, options) {
2221
2221
  json(response, 200, { kind: "live", channel: channelId, name: entry.title });
2222
2222
  return;
2223
2223
  }
2224
+ // Go live with it. The same channel a viewer would get on demand, but
2225
+ // kept: it stays up with nobody watching, it is written down so a
2226
+ // restart puts it back, and the directory hears about it now. A film
2227
+ // goes on the air the same way -- read at its own pace from the start,
2228
+ // so everybody who opens the link sees the same minute of it.
2229
+ if (sub === "live" && request.method === "POST") {
2230
+ if (!options.channels) {
2231
+ json(response, 503, { error: "this server cannot carry channels" });
2232
+ return;
2233
+ }
2234
+ const channelId = cleanId(`cat-${entry.id}`);
2235
+ if (!options.channels.has(channelId)) {
2236
+ const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source);
2237
+ if (!started) {
2238
+ json(response, 409, { error: "that channel is already starting" });
2239
+ return;
2240
+ }
2241
+ }
2242
+ options.channels.keep(channelId);
2243
+ if (options.rememberChannels) {
2244
+ options.rememberChannels(options.channels.list()
2245
+ .filter((one) => one.via === "pull" && one.source && !options.channels?.isEphemeral(one.id))
2246
+ .map((one) => ({ id: one.id, name: one.name, source: one.source })));
2247
+ }
2248
+ void options.live?.announce?.();
2249
+ json(response, 200, { channel: channelId, name: entry.title, kind: entry.live ? "live" : "vod" });
2250
+ return;
2251
+ }
2224
2252
  if (sub === "stream" && request.method === "GET") {
2225
2253
  if (!options.media) {
2226
2254
  json(response, 403, { error: "media streaming is off" });
@@ -2332,6 +2360,27 @@ export function createHandler(engine, options) {
2332
2360
  json(response, restarted ? 200 : 409, { ok: restarted });
2333
2361
  return;
2334
2362
  }
2363
+ /*
2364
+ * Keep a channel that was started on demand. Something being watched
2365
+ * from a catalog stops a minute after its last viewer leaves; going
2366
+ * live with it is asking it not to, and asking the directory to list
2367
+ * it now.
2368
+ */
2369
+ if (action === "keep") {
2370
+ if (!channels.has(id)) {
2371
+ json(response, 404, { error: "nothing is playing on that channel" });
2372
+ return;
2373
+ }
2374
+ channels.keep(id);
2375
+ if (options.rememberChannels) {
2376
+ options.rememberChannels(channels.list()
2377
+ .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
2378
+ .map((one) => ({ id: one.id, name: one.name, source: one.source })));
2379
+ }
2380
+ void options.live?.announce?.();
2381
+ json(response, 200, { ok: true });
2382
+ return;
2383
+ }
2335
2384
  /**
2336
2385
  * Carry a source of our own, rather than waiting to be sent one.
2337
2386
  *
@@ -3596,6 +3645,13 @@ export async function serve(argv, version = "0.1.0") {
3596
3645
  publisher = null;
3597
3646
  listing = null;
3598
3647
  },
3648
+ announce: async () => {
3649
+ if (publisher === null)
3650
+ return;
3651
+ const renewed = await publisher.announce();
3652
+ if (renewed)
3653
+ listing = renewed;
3654
+ },
3599
3655
  },
3600
3656
  ...(ingest ? { ingest } : {}),
3601
3657
  broadcaster,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/channels.ts CHANGED
@@ -486,6 +486,13 @@ export class Channel {
486
486
  };
487
487
  }
488
488
 
489
+ /** Stay up with nobody watching: no longer on demand. */
490
+ keep(): void {
491
+ this.ephemeral = false;
492
+ if (this.idle) clearTimeout(this.idle);
493
+ this.idle = null;
494
+ }
495
+
489
496
  /** Nobody is watching an on-demand channel: give it a minute, then stop. */
490
497
  private idleOut(): void {
491
498
  if (this.idle) clearTimeout(this.idle);
@@ -649,6 +656,25 @@ export class Channels {
649
656
  if (channel.listeners.size === 0) channel.listen({ write: () => true, end: () => undefined })();
650
657
  }
651
658
 
659
+ /**
660
+ * The opposite: a channel that stays up with nobody watching.
661
+ *
662
+ * Going live with something from a catalog turns the on-demand channel it
663
+ * was being watched on into a broadcast -- listed, shareable, and still
664
+ * there when the person who started it closes their tab.
665
+ */
666
+ keep(id: string): boolean {
667
+ const channel = this.open.get(id);
668
+ if (!channel) return false;
669
+ channel.keep();
670
+ return true;
671
+ }
672
+
673
+ /** Whether a channel stops itself when its last viewer leaves. */
674
+ isEphemeral(id: string): boolean {
675
+ return this.open.get(id)?.ephemeral === true;
676
+ }
677
+
652
678
  /** How many on-demand channels are up, for a ceiling on decoders. */
653
679
  get ephemeralCount(): number {
654
680
  let total = 0;