nixamp 0.7.35 → 0.7.36

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.
package/dist/audio.js CHANGED
@@ -190,7 +190,9 @@ export class Stream {
190
190
  this.output.stdin?.on("error", () => { });
191
191
  }
192
192
  let stderr = "";
193
- this.decoder.stderr?.on("data", (c) => { stderr += c.toString(); });
193
+ // The tail only: what went wrong is on the last line, and a film with a
194
+ // damaged audio track can say so once a frame for two hours.
195
+ this.decoder.stderr?.on("data", (c) => { stderr = (stderr + c.toString()).slice(-2000); });
194
196
  this.decoder.stdout?.on("data", (chunk) => {
195
197
  if (this.stopped || generation !== this.generation)
196
198
  return;
@@ -23,11 +23,23 @@ export interface ChannelInfo {
23
23
  kind?: "audio" | "video";
24
24
  /** For a channel we pull ourselves: where from. Never shown to a listener. */
25
25
  source?: string;
26
+ /** The last thing ffmpeg complained about, for whoever administers this. */
27
+ error?: string;
28
+ /** How many times the source has been dialled again since it started. */
29
+ redials?: number;
26
30
  }
27
31
  /** How long to wait before dialling a dropped source again. */
28
32
  export declare const REDIAL = 2000;
29
33
  /** How many times in a row a source may fail without ever sending anything. */
30
34
  export declare const GIVE_UP = 5;
35
+ /**
36
+ * How long a pulled source may say nothing before it is treated as gone.
37
+ *
38
+ * ffmpeg's own reconnect covers a connection that errors. It does not cover
39
+ * one that simply stops sending, and neither does anything else: a television
40
+ * channel that is quiet for half a minute is not being quiet, it is dead.
41
+ */
42
+ export declare const STALL = 30000;
31
43
  /** A name that can sit in a URL and be read back in a list. */
32
44
  export declare function cleanId(value: unknown, fallback?: string): string;
33
45
  export interface ChannelOptions {
@@ -54,6 +66,10 @@ export declare class Channel {
54
66
  private redial;
55
67
  private failures;
56
68
  private timer;
69
+ /** Fires when a pulled source has said nothing for STALL. */
70
+ private watchdog;
71
+ private stall;
72
+ private stderr;
57
73
  constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
58
74
  start(format: string): void;
59
75
  /**
@@ -69,7 +85,33 @@ export declare class Channel {
69
85
  * because you looked away, and a room where the picture depends on who is
70
86
  * in it is not a room anybody can be invited to.
71
87
  */
72
- pull(source: string, encode: string[], paced?: boolean): void;
88
+ pull(source: string, encode: string[], paced?: boolean, stall?: number): void;
89
+ /**
90
+ * Start the source over, now.
91
+ *
92
+ * For a pulled channel only: a publisher's stream cannot be dialled again
93
+ * from this end. The current ffmpeg is killed and a new one started at
94
+ * once, with the count of failures cleared -- somebody asking for this has
95
+ * decided the thing is worth another go, and should not inherit the four
96
+ * strikes a dead CDN ran up an hour ago.
97
+ */
98
+ restart(): boolean;
99
+ /**
100
+ * The stream that was is over; the next ffmpeg is a new one.
101
+ *
102
+ * New opening boxes, timestamps from zero again. Whoever was listening
103
+ * cannot follow that mid-picture, and a newcomer must not be handed the old
104
+ * opening boxes in front of the new fragments -- so the header is dropped
105
+ * and the audience is ended, to come back to the stream as it now is. The
106
+ * player rejoins on its own. Done the moment the source is known to be
107
+ * gone, not when the redial happens: somebody joining in between gets the
108
+ * new beginning as it is written, rather than a stale one first.
109
+ */
110
+ private startOver;
111
+ /** Expect output within STALL, or treat the source as gone and dial again. */
112
+ private rearm;
113
+ /** End everybody listening; the stream they were on is over. */
114
+ private hangUp;
73
115
  /**
74
116
  * A source that stopped. Try it again, unless it never worked at all.
75
117
  *
@@ -130,7 +172,15 @@ export declare class Channels {
130
172
  * about it is the same too, which is the point -- a re-stream stops being a
131
173
  * special case and becomes one more thing that is on.
132
174
  */
133
- pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean): Channel | null;
175
+ pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number): Channel | null;
176
+ /**
177
+ * Dial a pulled channel's source again, now. False for a channel that is
178
+ * not there or is not ours to dial: a publisher's stream restarts at the
179
+ * publisher's end.
180
+ */
181
+ restart(id: string): boolean;
182
+ /** Whether a channel is one we fetch ourselves, and so can start over. */
183
+ pulled(id: string): boolean;
134
184
  /** What a listener should be told this channel is. */
135
185
  contentType(id: string): string;
136
186
  /** Attach a listener, or null when nothing is playing on that channel. */
package/dist/channels.js CHANGED
@@ -21,6 +21,40 @@ import { Fragments } from "./fragments.js";
21
21
  export const REDIAL = 2000;
22
22
  /** How many times in a row a source may fail without ever sending anything. */
23
23
  export const GIVE_UP = 5;
24
+ /**
25
+ * How long a pulled source may say nothing before it is treated as gone.
26
+ *
27
+ * ffmpeg's own reconnect covers a connection that errors. It does not cover
28
+ * one that simply stops sending, and neither does anything else: a television
29
+ * channel that is quiet for half a minute is not being quiet, it is dead.
30
+ */
31
+ export const STALL = 30_000;
32
+ /** How much of what ffmpeg said to keep, for the last line when it dies. */
33
+ const TAIL = 2000;
34
+ /**
35
+ * Read everything a child says on stderr, keeping only the end of it.
36
+ *
37
+ * This is not optional. A pipe nobody reads fills, at 64 KiB on Linux, and
38
+ * the child then blocks on its next write to it -- every thread it has waits
39
+ * on the one that is stuck, and it produces nothing more, for ever, without
40
+ * exiting. A channel carrying an IPTV transport stream logs a line for every
41
+ * corrupt packet, and over twelve hours that is more than 64 KiB. Measured on
42
+ * the real server: CNN "on the air" with a full stderr socket, its decoder
43
+ * thread asleep in the kernel on that write, its byte count frozen, and a
44
+ * listener handed the opening boxes and then nothing at all.
45
+ */
46
+ function drain(stream, keep) {
47
+ let tail = "";
48
+ stream?.on("data", (chunk) => {
49
+ tail = (tail + chunk.toString("utf8")).slice(-TAIL);
50
+ keep(tail);
51
+ });
52
+ stream?.on("error", () => undefined);
53
+ }
54
+ /** The last thing ffmpeg said, which is where it says what went wrong. */
55
+ function lastLine(tail) {
56
+ return tail.trim().split("\n").pop() ?? "";
57
+ }
24
58
  /** A name that can sit in a URL and be read back in a list. */
25
59
  export function cleanId(value, fallback = "main") {
26
60
  if (typeof value !== "string")
@@ -47,6 +81,10 @@ export class Channel {
47
81
  redial = null;
48
82
  failures = 0;
49
83
  timer = null;
84
+ /** Fires when a pulled source has said nothing for STALL. */
85
+ watchdog = null;
86
+ stall = STALL;
87
+ stderr = "";
50
88
  constructor(info, options, onGone) {
51
89
  this.info = info;
52
90
  this.options = options;
@@ -72,6 +110,7 @@ export class Channel {
72
110
  this.info.bytes += chunk.byteLength;
73
111
  this.send(chunk);
74
112
  });
113
+ drain(child.stderr, (tail) => { this.stderr = tail; });
75
114
  // A publisher that hangs up mid-write breaks the pipe, and an unhandled
76
115
  // EPIPE takes the whole server with it.
77
116
  child.stdin?.on("error", () => this.close());
@@ -94,7 +133,8 @@ export class Channel {
94
133
  * because you looked away, and a room where the picture depends on who is
95
134
  * in it is not a room anybody can be invited to.
96
135
  */
97
- pull(source, encode, paced = true) {
136
+ pull(source, encode, paced = true, stall = STALL) {
137
+ this.stall = stall;
98
138
  if (this.info.kind === "video")
99
139
  this.fragments = new Fragments();
100
140
  const [command, ...prefix] = this.options.ffmpeg;
@@ -102,6 +142,7 @@ export class Channel {
102
142
  const dial = () => {
103
143
  if (this.closing)
104
144
  return;
145
+ this.stderr = "";
105
146
  const child = spawn(command, [
106
147
  ...prefix,
107
148
  "-hide_banner",
@@ -110,6 +151,11 @@ export class Channel {
110
151
  // the first time a CDN hiccups is not a channel anybody can rely
111
152
  // on. ffmpeg redials on its own before we have to.
112
153
  ...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
154
+ // A connection that stops answering is an error after this long,
155
+ // and an error is a thing the reconnect above knows what to do
156
+ // with. Without it a silent socket is waited on for ever. In
157
+ // microseconds, as ffmpeg wants it.
158
+ ...(remote ? ["-rw_timeout", String(stall * 1000)] : []),
113
159
  // Real time, always. A file read as fast as the disk allows is an
114
160
  // hour of film in ninety seconds and a room that cannot be in it
115
161
  // together; a live source is already paced and loses nothing.
@@ -119,20 +165,102 @@ export class Channel {
119
165
  "pipe:1",
120
166
  ], { stdio: ["ignore", "pipe", "pipe"] });
121
167
  let sent = false;
168
+ this.child = child;
169
+ this.rearm(child);
122
170
  child.stdout?.on("data", (chunk) => {
171
+ // An ffmpeg that was replaced can still have a chunk in the pipe.
172
+ if (this.child !== child)
173
+ return;
123
174
  sent = true;
124
175
  this.info.bytes += chunk.byteLength;
176
+ this.rearm(child);
125
177
  this.emit(chunk);
126
178
  });
127
179
  child.stdout?.on("error", () => undefined);
128
- child.on("error", () => this.dropped(sent));
129
- child.on("close", () => this.dropped(sent));
130
- this.child = child;
180
+ drain(child.stderr, (tail) => { this.stderr = tail; });
181
+ // Only the ffmpeg we are currently running gets to say the source
182
+ // dropped. One that was killed to make way for a restart is not news.
183
+ child.on("error", () => { if (this.child === child)
184
+ this.dropped(sent); });
185
+ child.on("close", () => { if (this.child === child)
186
+ this.dropped(sent); });
131
187
  };
132
188
  this.redial = dial;
133
189
  dial();
134
190
  this.options.onStart?.(this.info);
135
191
  }
192
+ /**
193
+ * Start the source over, now.
194
+ *
195
+ * For a pulled channel only: a publisher's stream cannot be dialled again
196
+ * from this end. The current ffmpeg is killed and a new one started at
197
+ * once, with the count of failures cleared -- somebody asking for this has
198
+ * decided the thing is worth another go, and should not inherit the four
199
+ * strikes a dead CDN ran up an hour ago.
200
+ */
201
+ restart() {
202
+ const dial = this.redial;
203
+ if (!dial || this.closing)
204
+ return false;
205
+ if (this.timer)
206
+ clearTimeout(this.timer);
207
+ this.timer = null;
208
+ if (this.watchdog)
209
+ clearTimeout(this.watchdog);
210
+ this.watchdog = null;
211
+ this.failures = 0;
212
+ this.info.redials = (this.info.redials ?? 0) + 1;
213
+ this.info.error = undefined;
214
+ const old = this.child;
215
+ this.child = null;
216
+ old?.kill("SIGKILL");
217
+ this.startOver();
218
+ dial();
219
+ return true;
220
+ }
221
+ /**
222
+ * The stream that was is over; the next ffmpeg is a new one.
223
+ *
224
+ * New opening boxes, timestamps from zero again. Whoever was listening
225
+ * cannot follow that mid-picture, and a newcomer must not be handed the old
226
+ * opening boxes in front of the new fragments -- so the header is dropped
227
+ * and the audience is ended, to come back to the stream as it now is. The
228
+ * player rejoins on its own. Done the moment the source is known to be
229
+ * gone, not when the redial happens: somebody joining in between gets the
230
+ * new beginning as it is written, rather than a stale one first.
231
+ */
232
+ startOver() {
233
+ if (this.info.kind === "video")
234
+ this.fragments = new Fragments();
235
+ this.hangUp();
236
+ }
237
+ /** Expect output within STALL, or treat the source as gone and dial again. */
238
+ rearm(child) {
239
+ if (this.watchdog)
240
+ clearTimeout(this.watchdog);
241
+ this.watchdog = setTimeout(() => {
242
+ this.watchdog = null;
243
+ if (this.child !== child || this.closing)
244
+ return;
245
+ this.info.error = `no data from the source for ${Math.round(this.stall / 1000)}s`;
246
+ // Its close handler is what dials again.
247
+ child.kill("SIGKILL");
248
+ }, this.stall);
249
+ this.watchdog.unref?.();
250
+ }
251
+ /** End everybody listening; the stream they were on is over. */
252
+ hangUp() {
253
+ for (const listener of this.listeners) {
254
+ try {
255
+ listener.end();
256
+ }
257
+ catch {
258
+ // Gone already.
259
+ }
260
+ }
261
+ this.listeners.clear();
262
+ this.info.listeners = 0;
263
+ }
136
264
  /**
137
265
  * A source that stopped. Try it again, unless it never worked at all.
138
266
  *
@@ -144,11 +272,19 @@ export class Channel {
144
272
  if (this.closing || !this.redial)
145
273
  return;
146
274
  this.child = null;
275
+ if (this.watchdog)
276
+ clearTimeout(this.watchdog);
277
+ this.watchdog = null;
278
+ const said = lastLine(this.stderr);
279
+ if (said)
280
+ this.info.error = said;
147
281
  this.failures = sent ? 0 : this.failures + 1;
148
282
  if (this.failures >= GIVE_UP) {
149
283
  this.close();
150
284
  return;
151
285
  }
286
+ this.info.redials = (this.info.redials ?? 0) + 1;
287
+ this.startOver();
152
288
  const dial = this.redial;
153
289
  this.timer = setTimeout(() => {
154
290
  this.timer = null;
@@ -234,6 +370,12 @@ export class Channel {
234
370
  if (this.timer)
235
371
  clearTimeout(this.timer);
236
372
  this.timer = null;
373
+ if (this.watchdog)
374
+ clearTimeout(this.watchdog);
375
+ this.watchdog = null;
376
+ const said = lastLine(this.stderr);
377
+ if (said && !this.info.error)
378
+ this.info.error = said;
237
379
  const child = this.child;
238
380
  this.child = null;
239
381
  try {
@@ -318,7 +460,7 @@ export class Channels {
318
460
  * about it is the same too, which is the point -- a re-stream stops being a
319
461
  * special case and becomes one more thing that is on.
320
462
  */
321
- pull(id, name, source, encode, kind, paced = true) {
463
+ pull(id, name, source, encode, kind, paced = true, stall = STALL) {
322
464
  if (this.open.has(id))
323
465
  return null;
324
466
  const channel = new Channel({
@@ -333,9 +475,21 @@ export class Channels {
333
475
  source,
334
476
  }, this.options, (gone) => this.open.delete(gone));
335
477
  this.open.set(id, channel);
336
- channel.pull(source, encode, paced);
478
+ channel.pull(source, encode, paced, stall);
337
479
  return channel;
338
480
  }
481
+ /**
482
+ * Dial a pulled channel's source again, now. False for a channel that is
483
+ * not there or is not ours to dial: a publisher's stream restarts at the
484
+ * publisher's end.
485
+ */
486
+ restart(id) {
487
+ return this.open.get(id)?.restart() ?? false;
488
+ }
489
+ /** Whether a channel is one we fetch ourselves, and so can start over. */
490
+ pulled(id) {
491
+ return this.open.get(id)?.info.via === "pull";
492
+ }
339
493
  /** What a listener should be told this channel is. */
340
494
  contentType(id) {
341
495
  return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
package/dist/rtmp-in.js CHANGED
@@ -59,6 +59,10 @@ export class RtmpListeners {
59
59
  channel?.feed(chunk);
60
60
  });
61
61
  child.stdout?.on("error", () => child.kill("SIGKILL"));
62
+ // Read and dropped. A pipe nobody reads fills at 64 KiB, and ffmpeg then
63
+ // blocks on its next complaint and stops producing anything -- a
64
+ // publisher whose stream hiccups enough would take the slot down with it.
65
+ child.stderr?.resume();
62
66
  child.on("error", () => this.done(slot, child, channel));
63
67
  child.on("close", () => this.done(slot, child, channel));
64
68
  }
package/dist/server.js CHANGED
@@ -1774,6 +1774,12 @@ export function createHandler(engine, options) {
1774
1774
  via: one.via,
1775
1775
  listeners: one.listeners,
1776
1776
  startedAt: one.startedAt,
1777
+ // Whether it has a picture, so the page puts it in the element
1778
+ // that can show one. Never the source: that is the owner's.
1779
+ kind: one.kind ?? "audio",
1780
+ // How it has been going, for whoever may do something about it.
1781
+ redials: one.redials ?? 0,
1782
+ error: one.error ?? "",
1777
1783
  })),
1778
1784
  // Anything re-streamed into this server is a live stream too, and was
1779
1785
  // sitting in the middle of the playlist among the files -- which is
@@ -1797,7 +1803,13 @@ export function createHandler(engine, options) {
1797
1803
  // devices can publish at once, each to their own channel, and a listener
1798
1804
  // picks which to hear.
1799
1805
  if (path === "/api/channels" && options.channels) {
1800
- json(response, 200, { channels: options.channels.list(), listeners: options.channels.listeners });
1806
+ // Without the source. Anyone holding the listen link may ask what is
1807
+ // on, and the address a channel is pulled from is the one thing about
1808
+ // it that is not theirs to have.
1809
+ json(response, 200, {
1810
+ channels: options.channels.list().map(({ source: _source, ...shown }) => shown),
1811
+ listeners: options.channels.listeners,
1812
+ });
1801
1813
  return;
1802
1814
  }
1803
1815
  // Publishing. Anyone with the control link may; listening to the result is
@@ -1852,6 +1864,27 @@ export function createHandler(engine, options) {
1852
1864
  json(response, 405, { error: "GET, POST or DELETE" });
1853
1865
  return;
1854
1866
  }
1867
+ /**
1868
+ * Dial the source again, now.
1869
+ *
1870
+ * The thing an administrator reaches for when a channel says it is on
1871
+ * the air and shows nobody anything. It is what fixed CNN by hand --
1872
+ * take it off, put it back -- without having to know the source, which
1873
+ * a browser is never told.
1874
+ */
1875
+ if (action === "restart") {
1876
+ if (!channels.has(id)) {
1877
+ json(response, 404, { error: "nothing is playing on that channel" });
1878
+ return;
1879
+ }
1880
+ if (!channels.pulled(id)) {
1881
+ json(response, 409, { error: "that channel is published into this server; restart it at the publisher" });
1882
+ return;
1883
+ }
1884
+ const restarted = channels.restart(id);
1885
+ json(response, restarted ? 200 : 409, { ok: restarted });
1886
+ return;
1887
+ }
1855
1888
  /**
1856
1889
  * Carry a source of our own, rather than waiting to be sent one.
1857
1890
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.35",
3
+ "version": "0.7.36",
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/audio.ts CHANGED
@@ -218,7 +218,9 @@ export class Stream {
218
218
  }
219
219
 
220
220
  let stderr = "";
221
- this.decoder.stderr?.on("data", (c: Buffer) => { stderr += c.toString(); });
221
+ // The tail only: what went wrong is on the last line, and a film with a
222
+ // damaged audio track can say so once a frame for two hours.
223
+ this.decoder.stderr?.on("data", (c: Buffer) => { stderr = (stderr + c.toString()).slice(-2000); });
222
224
 
223
225
  this.decoder.stdout?.on("data", (chunk: Buffer) => {
224
226
  if (this.stopped || generation !== this.generation) return;