nixamp 0.7.35 → 0.7.37
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 +3 -1
- package/dist/channels.d.ts +71 -2
- package/dist/channels.js +195 -6
- package/dist/rtmp-in.js +4 -0
- package/dist/server.d.ts +11 -1
- package/dist/server.js +81 -11
- package/package.json +1 -1
- package/src/audio.ts +3 -1
- package/src/channels.ts +213 -5
- package/src/rtmp-in.ts +4 -0
- package/src/server.ts +96 -12
- package/web/dist/assets/{hls-3VKVEQE3-DL_mIqWp.js → hls-3VKVEQE3-DbzFtX7t.js} +1 -1
- package/web/dist/assets/index-DGi1TAtn.js +1 -0
- package/web/dist/assets/{index-DDa16PVk.css → index-PhllvN11.css} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-UN1L-F7w.js → mpegts-LO6RVLD6-pqyQjSte.js} +1 -1
- package/web/dist/assets/{mpegts-BowzC-37.js → mpegts-Nijsm5UM.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/install.sh +29 -2
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-C_j8Onbv.js +0 -1
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
|
-
|
|
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;
|
package/dist/channels.d.ts
CHANGED
|
@@ -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. */
|
|
@@ -148,5 +198,24 @@ export declare class Channels {
|
|
|
148
198
|
stop(id: string): boolean;
|
|
149
199
|
stopAll(): void;
|
|
150
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* The channels a server pulls itself, remembered across a restart.
|
|
203
|
+
*
|
|
204
|
+
* A server is restarted to pick up a new version, which is to say often, and
|
|
205
|
+
* every restart used to take CNN off the air until somebody noticed and put
|
|
206
|
+
* it back by hand. A publisher's stream cannot be remembered -- it restarts at
|
|
207
|
+
* the publisher's end -- but a pulled one is a name and a URL, and a name and
|
|
208
|
+
* a URL can be written down.
|
|
209
|
+
*
|
|
210
|
+
* Keyed by port, like the keys, because two servers on one machine are two
|
|
211
|
+
* different line-ups.
|
|
212
|
+
*/
|
|
213
|
+
export interface RememberedChannel {
|
|
214
|
+
id: string;
|
|
215
|
+
name: string;
|
|
216
|
+
source: string;
|
|
217
|
+
}
|
|
218
|
+
export declare function rememberedChannels(dir: string, port: number): RememberedChannel[];
|
|
219
|
+
export declare function rememberChannels(dir: string, port: number, list: RememberedChannel[]): void;
|
|
151
220
|
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
152
221
|
export declare function generatedId(): string;
|
package/dist/channels.js
CHANGED
|
@@ -16,11 +16,47 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
19
21
|
import { Fragments } from "./fragments.js";
|
|
20
22
|
/** How long to wait before dialling a dropped source again. */
|
|
21
23
|
export const REDIAL = 2000;
|
|
22
24
|
/** How many times in a row a source may fail without ever sending anything. */
|
|
23
25
|
export const GIVE_UP = 5;
|
|
26
|
+
/**
|
|
27
|
+
* How long a pulled source may say nothing before it is treated as gone.
|
|
28
|
+
*
|
|
29
|
+
* ffmpeg's own reconnect covers a connection that errors. It does not cover
|
|
30
|
+
* one that simply stops sending, and neither does anything else: a television
|
|
31
|
+
* channel that is quiet for half a minute is not being quiet, it is dead.
|
|
32
|
+
*/
|
|
33
|
+
export const STALL = 30_000;
|
|
34
|
+
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
35
|
+
const TAIL = 2000;
|
|
36
|
+
/**
|
|
37
|
+
* Read everything a child says on stderr, keeping only the end of it.
|
|
38
|
+
*
|
|
39
|
+
* This is not optional. A pipe nobody reads fills, at 64 KiB on Linux, and
|
|
40
|
+
* the child then blocks on its next write to it -- every thread it has waits
|
|
41
|
+
* on the one that is stuck, and it produces nothing more, for ever, without
|
|
42
|
+
* exiting. A channel carrying an IPTV transport stream logs a line for every
|
|
43
|
+
* corrupt packet, and over twelve hours that is more than 64 KiB. Measured on
|
|
44
|
+
* the real server: CNN "on the air" with a full stderr socket, its decoder
|
|
45
|
+
* thread asleep in the kernel on that write, its byte count frozen, and a
|
|
46
|
+
* listener handed the opening boxes and then nothing at all.
|
|
47
|
+
*/
|
|
48
|
+
function drain(stream, keep) {
|
|
49
|
+
let tail = "";
|
|
50
|
+
stream?.on("data", (chunk) => {
|
|
51
|
+
tail = (tail + chunk.toString("utf8")).slice(-TAIL);
|
|
52
|
+
keep(tail);
|
|
53
|
+
});
|
|
54
|
+
stream?.on("error", () => undefined);
|
|
55
|
+
}
|
|
56
|
+
/** The last thing ffmpeg said, which is where it says what went wrong. */
|
|
57
|
+
function lastLine(tail) {
|
|
58
|
+
return tail.trim().split("\n").pop() ?? "";
|
|
59
|
+
}
|
|
24
60
|
/** A name that can sit in a URL and be read back in a list. */
|
|
25
61
|
export function cleanId(value, fallback = "main") {
|
|
26
62
|
if (typeof value !== "string")
|
|
@@ -47,6 +83,10 @@ export class Channel {
|
|
|
47
83
|
redial = null;
|
|
48
84
|
failures = 0;
|
|
49
85
|
timer = null;
|
|
86
|
+
/** Fires when a pulled source has said nothing for STALL. */
|
|
87
|
+
watchdog = null;
|
|
88
|
+
stall = STALL;
|
|
89
|
+
stderr = "";
|
|
50
90
|
constructor(info, options, onGone) {
|
|
51
91
|
this.info = info;
|
|
52
92
|
this.options = options;
|
|
@@ -72,6 +112,7 @@ export class Channel {
|
|
|
72
112
|
this.info.bytes += chunk.byteLength;
|
|
73
113
|
this.send(chunk);
|
|
74
114
|
});
|
|
115
|
+
drain(child.stderr, (tail) => { this.stderr = tail; });
|
|
75
116
|
// A publisher that hangs up mid-write breaks the pipe, and an unhandled
|
|
76
117
|
// EPIPE takes the whole server with it.
|
|
77
118
|
child.stdin?.on("error", () => this.close());
|
|
@@ -94,7 +135,8 @@ export class Channel {
|
|
|
94
135
|
* because you looked away, and a room where the picture depends on who is
|
|
95
136
|
* in it is not a room anybody can be invited to.
|
|
96
137
|
*/
|
|
97
|
-
pull(source, encode, paced = true) {
|
|
138
|
+
pull(source, encode, paced = true, stall = STALL) {
|
|
139
|
+
this.stall = stall;
|
|
98
140
|
if (this.info.kind === "video")
|
|
99
141
|
this.fragments = new Fragments();
|
|
100
142
|
const [command, ...prefix] = this.options.ffmpeg;
|
|
@@ -102,6 +144,7 @@ export class Channel {
|
|
|
102
144
|
const dial = () => {
|
|
103
145
|
if (this.closing)
|
|
104
146
|
return;
|
|
147
|
+
this.stderr = "";
|
|
105
148
|
const child = spawn(command, [
|
|
106
149
|
...prefix,
|
|
107
150
|
"-hide_banner",
|
|
@@ -110,6 +153,11 @@ export class Channel {
|
|
|
110
153
|
// the first time a CDN hiccups is not a channel anybody can rely
|
|
111
154
|
// on. ffmpeg redials on its own before we have to.
|
|
112
155
|
...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
156
|
+
// A connection that stops answering is an error after this long,
|
|
157
|
+
// and an error is a thing the reconnect above knows what to do
|
|
158
|
+
// with. Without it a silent socket is waited on for ever. In
|
|
159
|
+
// microseconds, as ffmpeg wants it.
|
|
160
|
+
...(remote ? ["-rw_timeout", String(stall * 1000)] : []),
|
|
113
161
|
// Real time, always. A file read as fast as the disk allows is an
|
|
114
162
|
// hour of film in ninety seconds and a room that cannot be in it
|
|
115
163
|
// together; a live source is already paced and loses nothing.
|
|
@@ -119,20 +167,102 @@ export class Channel {
|
|
|
119
167
|
"pipe:1",
|
|
120
168
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
121
169
|
let sent = false;
|
|
170
|
+
this.child = child;
|
|
171
|
+
this.rearm(child);
|
|
122
172
|
child.stdout?.on("data", (chunk) => {
|
|
173
|
+
// An ffmpeg that was replaced can still have a chunk in the pipe.
|
|
174
|
+
if (this.child !== child)
|
|
175
|
+
return;
|
|
123
176
|
sent = true;
|
|
124
177
|
this.info.bytes += chunk.byteLength;
|
|
178
|
+
this.rearm(child);
|
|
125
179
|
this.emit(chunk);
|
|
126
180
|
});
|
|
127
181
|
child.stdout?.on("error", () => undefined);
|
|
128
|
-
child.
|
|
129
|
-
|
|
130
|
-
|
|
182
|
+
drain(child.stderr, (tail) => { this.stderr = tail; });
|
|
183
|
+
// Only the ffmpeg we are currently running gets to say the source
|
|
184
|
+
// dropped. One that was killed to make way for a restart is not news.
|
|
185
|
+
child.on("error", () => { if (this.child === child)
|
|
186
|
+
this.dropped(sent); });
|
|
187
|
+
child.on("close", () => { if (this.child === child)
|
|
188
|
+
this.dropped(sent); });
|
|
131
189
|
};
|
|
132
190
|
this.redial = dial;
|
|
133
191
|
dial();
|
|
134
192
|
this.options.onStart?.(this.info);
|
|
135
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Start the source over, now.
|
|
196
|
+
*
|
|
197
|
+
* For a pulled channel only: a publisher's stream cannot be dialled again
|
|
198
|
+
* from this end. The current ffmpeg is killed and a new one started at
|
|
199
|
+
* once, with the count of failures cleared -- somebody asking for this has
|
|
200
|
+
* decided the thing is worth another go, and should not inherit the four
|
|
201
|
+
* strikes a dead CDN ran up an hour ago.
|
|
202
|
+
*/
|
|
203
|
+
restart() {
|
|
204
|
+
const dial = this.redial;
|
|
205
|
+
if (!dial || this.closing)
|
|
206
|
+
return false;
|
|
207
|
+
if (this.timer)
|
|
208
|
+
clearTimeout(this.timer);
|
|
209
|
+
this.timer = null;
|
|
210
|
+
if (this.watchdog)
|
|
211
|
+
clearTimeout(this.watchdog);
|
|
212
|
+
this.watchdog = null;
|
|
213
|
+
this.failures = 0;
|
|
214
|
+
this.info.redials = (this.info.redials ?? 0) + 1;
|
|
215
|
+
this.info.error = undefined;
|
|
216
|
+
const old = this.child;
|
|
217
|
+
this.child = null;
|
|
218
|
+
old?.kill("SIGKILL");
|
|
219
|
+
this.startOver();
|
|
220
|
+
dial();
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The stream that was is over; the next ffmpeg is a new one.
|
|
225
|
+
*
|
|
226
|
+
* New opening boxes, timestamps from zero again. Whoever was listening
|
|
227
|
+
* cannot follow that mid-picture, and a newcomer must not be handed the old
|
|
228
|
+
* opening boxes in front of the new fragments -- so the header is dropped
|
|
229
|
+
* and the audience is ended, to come back to the stream as it now is. The
|
|
230
|
+
* player rejoins on its own. Done the moment the source is known to be
|
|
231
|
+
* gone, not when the redial happens: somebody joining in between gets the
|
|
232
|
+
* new beginning as it is written, rather than a stale one first.
|
|
233
|
+
*/
|
|
234
|
+
startOver() {
|
|
235
|
+
if (this.info.kind === "video")
|
|
236
|
+
this.fragments = new Fragments();
|
|
237
|
+
this.hangUp();
|
|
238
|
+
}
|
|
239
|
+
/** Expect output within STALL, or treat the source as gone and dial again. */
|
|
240
|
+
rearm(child) {
|
|
241
|
+
if (this.watchdog)
|
|
242
|
+
clearTimeout(this.watchdog);
|
|
243
|
+
this.watchdog = setTimeout(() => {
|
|
244
|
+
this.watchdog = null;
|
|
245
|
+
if (this.child !== child || this.closing)
|
|
246
|
+
return;
|
|
247
|
+
this.info.error = `no data from the source for ${Math.round(this.stall / 1000)}s`;
|
|
248
|
+
// Its close handler is what dials again.
|
|
249
|
+
child.kill("SIGKILL");
|
|
250
|
+
}, this.stall);
|
|
251
|
+
this.watchdog.unref?.();
|
|
252
|
+
}
|
|
253
|
+
/** End everybody listening; the stream they were on is over. */
|
|
254
|
+
hangUp() {
|
|
255
|
+
for (const listener of this.listeners) {
|
|
256
|
+
try {
|
|
257
|
+
listener.end();
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Gone already.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
this.listeners.clear();
|
|
264
|
+
this.info.listeners = 0;
|
|
265
|
+
}
|
|
136
266
|
/**
|
|
137
267
|
* A source that stopped. Try it again, unless it never worked at all.
|
|
138
268
|
*
|
|
@@ -144,11 +274,19 @@ export class Channel {
|
|
|
144
274
|
if (this.closing || !this.redial)
|
|
145
275
|
return;
|
|
146
276
|
this.child = null;
|
|
277
|
+
if (this.watchdog)
|
|
278
|
+
clearTimeout(this.watchdog);
|
|
279
|
+
this.watchdog = null;
|
|
280
|
+
const said = lastLine(this.stderr);
|
|
281
|
+
if (said)
|
|
282
|
+
this.info.error = said;
|
|
147
283
|
this.failures = sent ? 0 : this.failures + 1;
|
|
148
284
|
if (this.failures >= GIVE_UP) {
|
|
149
285
|
this.close();
|
|
150
286
|
return;
|
|
151
287
|
}
|
|
288
|
+
this.info.redials = (this.info.redials ?? 0) + 1;
|
|
289
|
+
this.startOver();
|
|
152
290
|
const dial = this.redial;
|
|
153
291
|
this.timer = setTimeout(() => {
|
|
154
292
|
this.timer = null;
|
|
@@ -234,6 +372,12 @@ export class Channel {
|
|
|
234
372
|
if (this.timer)
|
|
235
373
|
clearTimeout(this.timer);
|
|
236
374
|
this.timer = null;
|
|
375
|
+
if (this.watchdog)
|
|
376
|
+
clearTimeout(this.watchdog);
|
|
377
|
+
this.watchdog = null;
|
|
378
|
+
const said = lastLine(this.stderr);
|
|
379
|
+
if (said && !this.info.error)
|
|
380
|
+
this.info.error = said;
|
|
237
381
|
const child = this.child;
|
|
238
382
|
this.child = null;
|
|
239
383
|
try {
|
|
@@ -318,7 +462,7 @@ export class Channels {
|
|
|
318
462
|
* about it is the same too, which is the point -- a re-stream stops being a
|
|
319
463
|
* special case and becomes one more thing that is on.
|
|
320
464
|
*/
|
|
321
|
-
pull(id, name, source, encode, kind, paced = true) {
|
|
465
|
+
pull(id, name, source, encode, kind, paced = true, stall = STALL) {
|
|
322
466
|
if (this.open.has(id))
|
|
323
467
|
return null;
|
|
324
468
|
const channel = new Channel({
|
|
@@ -333,9 +477,21 @@ export class Channels {
|
|
|
333
477
|
source,
|
|
334
478
|
}, this.options, (gone) => this.open.delete(gone));
|
|
335
479
|
this.open.set(id, channel);
|
|
336
|
-
channel.pull(source, encode, paced);
|
|
480
|
+
channel.pull(source, encode, paced, stall);
|
|
337
481
|
return channel;
|
|
338
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Dial a pulled channel's source again, now. False for a channel that is
|
|
485
|
+
* not there or is not ours to dial: a publisher's stream restarts at the
|
|
486
|
+
* publisher's end.
|
|
487
|
+
*/
|
|
488
|
+
restart(id) {
|
|
489
|
+
return this.open.get(id)?.restart() ?? false;
|
|
490
|
+
}
|
|
491
|
+
/** Whether a channel is one we fetch ourselves, and so can start over. */
|
|
492
|
+
pulled(id) {
|
|
493
|
+
return this.open.get(id)?.info.via === "pull";
|
|
494
|
+
}
|
|
339
495
|
/** What a listener should be told this channel is. */
|
|
340
496
|
contentType(id) {
|
|
341
497
|
return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
|
|
@@ -375,6 +531,39 @@ export class Channels {
|
|
|
375
531
|
channel.close();
|
|
376
532
|
}
|
|
377
533
|
}
|
|
534
|
+
const REMEMBERED = "channels.json";
|
|
535
|
+
export function rememberedChannels(dir, port) {
|
|
536
|
+
try {
|
|
537
|
+
const all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8"));
|
|
538
|
+
const list = all[String(port)];
|
|
539
|
+
if (!Array.isArray(list))
|
|
540
|
+
return [];
|
|
541
|
+
return list.filter((one) => typeof one === "object" && one !== null &&
|
|
542
|
+
typeof one.id === "string" &&
|
|
543
|
+
typeof one.name === "string" &&
|
|
544
|
+
typeof one.source === "string");
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return [];
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
export function rememberChannels(dir, port, list) {
|
|
551
|
+
let all = {};
|
|
552
|
+
try {
|
|
553
|
+
all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8"));
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// First time, or unreadable: start again rather than refuse to remember.
|
|
557
|
+
}
|
|
558
|
+
all[String(port)] = list;
|
|
559
|
+
try {
|
|
560
|
+
mkdirSync(dir, { recursive: true });
|
|
561
|
+
writeFileSync(join(dir, REMEMBERED), JSON.stringify(all, null, 2));
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
// A state directory that cannot be written costs a memory, not a stream.
|
|
565
|
+
}
|
|
566
|
+
}
|
|
378
567
|
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
379
568
|
export function generatedId() {
|
|
380
569
|
return `s${randomBytes(3).toString("hex")}`;
|
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.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type IncomingMessage, type Server, type ServerResponse } from "node:htt
|
|
|
2
2
|
import { Connections } from "./connections.ts";
|
|
3
3
|
import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast.ts";
|
|
4
4
|
import { Ingest } from "./ingest.ts";
|
|
5
|
-
import { Channels } from "./channels.ts";
|
|
5
|
+
import { Channels, type Channel, type RememberedChannel } from "./channels.ts";
|
|
6
6
|
import { Accounts } from "./accounts.ts";
|
|
7
7
|
import { Handles } from "./handles.ts";
|
|
8
8
|
import { OpenDirs } from "./opendirs.ts";
|
|
@@ -336,6 +336,14 @@ export declare class PlayerEngine implements Engine {
|
|
|
336
336
|
* looking at what is on wants "that album from the web", not every track in
|
|
337
337
|
* it. An entry names where to start, so clicking it plays.
|
|
338
338
|
*/
|
|
339
|
+
/**
|
|
340
|
+
* Probe a source and start carrying it as a channel of its own.
|
|
341
|
+
*
|
|
342
|
+
* Shared by the request that puts one on and the boot that puts remembered
|
|
343
|
+
* ones back, so that both agree on what a source is encoded as. Null when
|
|
344
|
+
* that channel id is already on.
|
|
345
|
+
*/
|
|
346
|
+
export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string): Promise<Channel | null>;
|
|
339
347
|
export declare function liveOnes(engine: Engine): {
|
|
340
348
|
name: string;
|
|
341
349
|
at: number;
|
|
@@ -403,6 +411,8 @@ export interface HandlerOptions {
|
|
|
403
411
|
ingest?: Ingest;
|
|
404
412
|
/** Several live streams at once, each with its own audience. */
|
|
405
413
|
channels?: Channels;
|
|
414
|
+
/** Write down the channels this server pulls, so a restart puts them back. */
|
|
415
|
+
rememberChannels?: (list: RememberedChannel[]) => void;
|
|
406
416
|
/** Live audio going out to RTMP. */
|
|
407
417
|
broadcaster?: Broadcaster;
|
|
408
418
|
/** Where a broadcast should send, and what it should look like. */
|
package/dist/server.js
CHANGED
|
@@ -19,7 +19,7 @@ import { readFileSync } from "node:fs";
|
|
|
19
19
|
import { Connections } from "./connections.js";
|
|
20
20
|
import { Broadcaster, DEFAULT_ENCODER, PRESETS, redact, } from "./broadcast.js";
|
|
21
21
|
import { Ingest, normaliseFormat } from "./ingest.js";
|
|
22
|
-
import { Channels, cleanId, generatedId } from "./channels.js";
|
|
22
|
+
import { Channels, cleanId, generatedId, rememberChannels, rememberedChannels, } from "./channels.js";
|
|
23
23
|
import { RtmpListeners } from "./rtmp-in.js";
|
|
24
24
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
|
|
25
25
|
import { anonymousHandle, Handles } from "./handles.js";
|
|
@@ -707,6 +707,23 @@ export class PlayerEngine {
|
|
|
707
707
|
* looking at what is on wants "that album from the web", not every track in
|
|
708
708
|
* it. An entry names where to start, so clicking it plays.
|
|
709
709
|
*/
|
|
710
|
+
/**
|
|
711
|
+
* Probe a source and start carrying it as a channel of its own.
|
|
712
|
+
*
|
|
713
|
+
* Shared by the request that puts one on and the boot that puts remembered
|
|
714
|
+
* ones back, so that both agree on what a source is encoded as. Null when
|
|
715
|
+
* that channel id is already on.
|
|
716
|
+
*/
|
|
717
|
+
export async function pullChannel(channels, ffprobe, id, name, source) {
|
|
718
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
|
|
719
|
+
const kind = codecs.video === "" ? "audio" : "video";
|
|
720
|
+
const encode = kind === "video"
|
|
721
|
+
? videoArgs(codecs)
|
|
722
|
+
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
723
|
+
// plays and the thing a listener can join halfway through.
|
|
724
|
+
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
725
|
+
return channels.pull(id, name, source, encode, kind);
|
|
726
|
+
}
|
|
710
727
|
export function liveOnes(engine) {
|
|
711
728
|
const tracks = engine.snapshot().tracks ?? [];
|
|
712
729
|
const found = new Map();
|
|
@@ -1774,6 +1791,12 @@ export function createHandler(engine, options) {
|
|
|
1774
1791
|
via: one.via,
|
|
1775
1792
|
listeners: one.listeners,
|
|
1776
1793
|
startedAt: one.startedAt,
|
|
1794
|
+
// Whether it has a picture, so the page puts it in the element
|
|
1795
|
+
// that can show one. Never the source: that is the owner's.
|
|
1796
|
+
kind: one.kind ?? "audio",
|
|
1797
|
+
// How it has been going, for whoever may do something about it.
|
|
1798
|
+
redials: one.redials ?? 0,
|
|
1799
|
+
error: one.error ?? "",
|
|
1777
1800
|
})),
|
|
1778
1801
|
// Anything re-streamed into this server is a live stream too, and was
|
|
1779
1802
|
// sitting in the middle of the playlist among the files -- which is
|
|
@@ -1797,7 +1820,13 @@ export function createHandler(engine, options) {
|
|
|
1797
1820
|
// devices can publish at once, each to their own channel, and a listener
|
|
1798
1821
|
// picks which to hear.
|
|
1799
1822
|
if (path === "/api/channels" && options.channels) {
|
|
1800
|
-
|
|
1823
|
+
// Without the source. Anyone holding the listen link may ask what is
|
|
1824
|
+
// on, and the address a channel is pulled from is the one thing about
|
|
1825
|
+
// it that is not theirs to have.
|
|
1826
|
+
json(response, 200, {
|
|
1827
|
+
channels: options.channels.list().map(({ source: _source, ...shown }) => shown),
|
|
1828
|
+
listeners: options.channels.listeners,
|
|
1829
|
+
});
|
|
1801
1830
|
return;
|
|
1802
1831
|
}
|
|
1803
1832
|
// Publishing. Anyone with the control link may; listening to the result is
|
|
@@ -1845,6 +1874,13 @@ export function createHandler(engine, options) {
|
|
|
1845
1874
|
// Asked once: the second call would answer false, having just stopped
|
|
1846
1875
|
// the thing it was asking about.
|
|
1847
1876
|
const stopped = channels.stop(id);
|
|
1877
|
+
// Taken off on purpose is forgotten on purpose: it must not come back
|
|
1878
|
+
// at the next restart.
|
|
1879
|
+
if (stopped && options.rememberChannels) {
|
|
1880
|
+
options.rememberChannels(channels.list()
|
|
1881
|
+
.filter((one) => one.via === "pull" && one.source)
|
|
1882
|
+
.map((one) => ({ id: one.id, name: one.name, source: one.source })));
|
|
1883
|
+
}
|
|
1848
1884
|
json(response, stopped ? 200 : 404, { ok: stopped });
|
|
1849
1885
|
return;
|
|
1850
1886
|
}
|
|
@@ -1852,6 +1888,27 @@ export function createHandler(engine, options) {
|
|
|
1852
1888
|
json(response, 405, { error: "GET, POST or DELETE" });
|
|
1853
1889
|
return;
|
|
1854
1890
|
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Dial the source again, now.
|
|
1893
|
+
*
|
|
1894
|
+
* The thing an administrator reaches for when a channel says it is on
|
|
1895
|
+
* the air and shows nobody anything. It is what fixed CNN by hand --
|
|
1896
|
+
* take it off, put it back -- without having to know the source, which
|
|
1897
|
+
* a browser is never told.
|
|
1898
|
+
*/
|
|
1899
|
+
if (action === "restart") {
|
|
1900
|
+
if (!channels.has(id)) {
|
|
1901
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
if (!channels.pulled(id)) {
|
|
1905
|
+
json(response, 409, { error: "that channel is published into this server; restart it at the publisher" });
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
const restarted = channels.restart(id);
|
|
1909
|
+
json(response, restarted ? 200 : 409, { ok: restarted });
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1855
1912
|
/**
|
|
1856
1913
|
* Carry a source of our own, rather than waiting to be sent one.
|
|
1857
1914
|
*
|
|
@@ -1894,19 +1951,20 @@ export function createHandler(engine, options) {
|
|
|
1894
1951
|
json(response, 409, { error: "that channel is already on" });
|
|
1895
1952
|
return;
|
|
1896
1953
|
}
|
|
1897
|
-
const
|
|
1898
|
-
const codecs = await codecsOf({ ffmpeg: [], ffprobe: probe, play: null }, source);
|
|
1899
|
-
const kind = codecs.video === "" ? "audio" : "video";
|
|
1900
|
-
const encode = kind === "video"
|
|
1901
|
-
? videoArgs(codecs)
|
|
1902
|
-
// No picture in it, so none is invented: MP3 is the thing every
|
|
1903
|
-
// browser plays and the thing a listener can join halfway through.
|
|
1904
|
-
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
1905
|
-
const channel = channels.pull(wanted, called, source, encode, kind);
|
|
1954
|
+
const channel = await pullChannel(channels, options.ffprobe ?? ["ffprobe"], wanted, called, source);
|
|
1906
1955
|
if (!channel) {
|
|
1907
1956
|
json(response, 409, { error: "that channel is already on" });
|
|
1908
1957
|
return;
|
|
1909
1958
|
}
|
|
1959
|
+
// Written down, so a restart puts it back on the air.
|
|
1960
|
+
if (options.rememberChannels) {
|
|
1961
|
+
options.rememberChannels([
|
|
1962
|
+
...channels.list()
|
|
1963
|
+
.filter((one) => one.via === "pull" && one.source && one.id !== wanted)
|
|
1964
|
+
.map((one) => ({ id: one.id, name: one.name, source: one.source })),
|
|
1965
|
+
{ id: wanted, name: called, source },
|
|
1966
|
+
]);
|
|
1967
|
+
}
|
|
1910
1968
|
json(response, 200, { ok: true, channel: channel.info });
|
|
1911
1969
|
return;
|
|
1912
1970
|
}
|
|
@@ -2777,6 +2835,17 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2777
2835
|
onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
2778
2836
|
onEnd: (info) => console.log(` "${info.id}" stopped.`),
|
|
2779
2837
|
});
|
|
2838
|
+
// The channels this server was carrying when it was last stopped, put back
|
|
2839
|
+
// on. A server is restarted to pick up a new version, which is often, and
|
|
2840
|
+
// every restart used to take CNN off the air until somebody noticed.
|
|
2841
|
+
const remembering = (list) => rememberChannels(stateDir(), options.port, list);
|
|
2842
|
+
for (const one of rememberedChannels(stateDir(), options.port)) {
|
|
2843
|
+
console.log(` Putting "${one.id}" (${one.name}) back on the air.`);
|
|
2844
|
+
void pullChannel(channels, tools.ffprobe, one.id, one.name, one.source).then((channel) => {
|
|
2845
|
+
if (!channel)
|
|
2846
|
+
console.log(` "${one.id}" is already on.`);
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2780
2849
|
const destinations = parseDestinations(options.rtmp);
|
|
2781
2850
|
const broadcaster = new Broadcaster(tools.ffmpeg);
|
|
2782
2851
|
const ingest = options.ingest
|
|
@@ -2961,6 +3030,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2961
3030
|
media: options.media,
|
|
2962
3031
|
owner,
|
|
2963
3032
|
channels,
|
|
3033
|
+
rememberChannels: remembering,
|
|
2964
3034
|
publishUrls: () => publishUrls,
|
|
2965
3035
|
serverName: options.name || hostname(),
|
|
2966
3036
|
homeSource: root,
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -218,7 +218,9 @@ export class Stream {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
let stderr = "";
|
|
221
|
-
|
|
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;
|