nixamp 0.7.34 → 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 +3 -1
- package/dist/channels.d.ts +109 -1
- package/dist/channels.js +300 -0
- package/dist/fragments.d.ts +57 -0
- package/dist/fragments.js +109 -0
- package/dist/rtmp-in.js +4 -0
- package/dist/server.js +111 -5
- package/package.json +1 -1
- package/src/audio.ts +3 -1
- package/src/channels.ts +325 -1
- package/src/fragments.ts +115 -0
- package/src/rtmp-in.ts +4 -0
- package/src/server.ts +116 -5
- package/web/dist/assets/{hls-3VKVEQE3-ZK5W33OV.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-DWE7mc-Q.js → mpegts-LO6RVLD6-pqyQjSte.js} +1 -1
- package/web/dist/assets/{mpegts--5mHOXme.js → mpegts-Nijsm5UM.js} +1 -1
- package/web/dist/index.html +4 -3
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-DFWY01QV.js +0 -1
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 } from "./channels.js";
|
|
22
|
+
import { Channels, cleanId, generatedId } 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";
|
|
@@ -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
|
-
|
|
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
|
|
@@ -1810,17 +1822,32 @@ export function createHandler(engine, options) {
|
|
|
1810
1822
|
if (action === undefined && request.method === "GET") {
|
|
1811
1823
|
// Listening. The response is the fan-out target: whatever ffmpeg
|
|
1812
1824
|
// produces for this channel is written to it until one end goes away.
|
|
1813
|
-
|
|
1814
|
-
if (detach === null) {
|
|
1825
|
+
if (!channels.has(id)) {
|
|
1815
1826
|
json(response, 404, { error: "nothing is playing on that channel" });
|
|
1816
1827
|
return;
|
|
1817
1828
|
}
|
|
1818
1829
|
watch(request, response, "stream", id);
|
|
1830
|
+
// Headers first, and then the listener.
|
|
1831
|
+
//
|
|
1832
|
+
// Attaching first was fine while a channel only ever wrote future
|
|
1833
|
+
// bytes. A video channel writes the opening boxes to a new listener
|
|
1834
|
+
// the moment it joins, and those went out before this response had
|
|
1835
|
+
// any headers at all -- so it committed as a bare 200 with no
|
|
1836
|
+
// content-type, ended immediately, and the picture was one kilobyte
|
|
1837
|
+
// long. Whether it happened depended on whether ffmpeg had produced
|
|
1838
|
+
// its header yet, which is why it looked intermittent.
|
|
1819
1839
|
response.writeHead(200, {
|
|
1820
1840
|
...CORS,
|
|
1821
|
-
|
|
1841
|
+
// Asked of the channel rather than assumed: a channel carrying
|
|
1842
|
+
// pictures that calls itself audio/mpeg plays as nothing at all.
|
|
1843
|
+
"content-type": channels.contentType(id),
|
|
1822
1844
|
"cache-control": "no-store",
|
|
1823
1845
|
});
|
|
1846
|
+
const detach = channels.listen(id, response);
|
|
1847
|
+
if (detach === null) {
|
|
1848
|
+
response.end();
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1824
1851
|
const leave = () => detach();
|
|
1825
1852
|
request.on("close", leave);
|
|
1826
1853
|
response.on("close", leave);
|
|
@@ -1837,6 +1864,85 @@ export function createHandler(engine, options) {
|
|
|
1837
1864
|
json(response, 405, { error: "GET, POST or DELETE" });
|
|
1838
1865
|
return;
|
|
1839
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
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Carry a source of our own, rather than waiting to be sent one.
|
|
1890
|
+
*
|
|
1891
|
+
* A re-stream used to be added to the playlist, where it became one
|
|
1892
|
+
* more track -- and a server plays one track at a time, so the second
|
|
1893
|
+
* channel you added sat there saying "stopped". Two channels are two
|
|
1894
|
+
* processes with two audiences and two addresses, which is what lets
|
|
1895
|
+
* one person watch the baseball while another watches the news, in two
|
|
1896
|
+
* tabs or in two panels of the same multiview.
|
|
1897
|
+
*/
|
|
1898
|
+
if (action === "pull") {
|
|
1899
|
+
let source = "";
|
|
1900
|
+
let called = "";
|
|
1901
|
+
try {
|
|
1902
|
+
const body = JSON.parse(await readBody(request));
|
|
1903
|
+
called = String(body.name ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 80);
|
|
1904
|
+
// A track number rather than a path: it is the server's own library
|
|
1905
|
+
// either way, and a number cannot name a file outside it.
|
|
1906
|
+
if (typeof body.at === "number" && Number.isInteger(body.at) && body.at >= 0) {
|
|
1907
|
+
source = engine.trackPath(body.at) ?? "";
|
|
1908
|
+
if (source === "") {
|
|
1909
|
+
json(response, 404, { error: "no track there" });
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
else {
|
|
1914
|
+
source = String(body.source ?? "").trim();
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
catch {
|
|
1918
|
+
json(response, 400, { error: "bad JSON" });
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
if (source === "") {
|
|
1922
|
+
json(response, 400, { error: "give a URL to carry, or a track to show" });
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
const wanted = cleanId(rawId, generatedId());
|
|
1926
|
+
if (channels.has(wanted)) {
|
|
1927
|
+
json(response, 409, { error: "that channel is already on" });
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const probe = options.ffprobe ?? ["ffprobe"];
|
|
1931
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: probe, play: null }, source);
|
|
1932
|
+
const kind = codecs.video === "" ? "audio" : "video";
|
|
1933
|
+
const encode = kind === "video"
|
|
1934
|
+
? videoArgs(codecs)
|
|
1935
|
+
// No picture in it, so none is invented: MP3 is the thing every
|
|
1936
|
+
// browser plays and the thing a listener can join halfway through.
|
|
1937
|
+
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
1938
|
+
const channel = channels.pull(wanted, called, source, encode, kind);
|
|
1939
|
+
if (!channel) {
|
|
1940
|
+
json(response, 409, { error: "that channel is already on" });
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
json(response, 200, { ok: true, channel: channel.info });
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1840
1946
|
const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
|
|
1841
1947
|
if (format === null) {
|
|
1842
1948
|
json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
|
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;
|
package/src/channels.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
18
|
import { randomBytes } from "node:crypto";
|
|
19
19
|
import type { Readable } from "node:stream";
|
|
20
|
+
import { Fragments } from "./fragments.ts";
|
|
20
21
|
|
|
21
22
|
/** Somewhere for a channel's audio to go. A response, in practice. */
|
|
22
23
|
export interface Listener {
|
|
@@ -31,10 +32,63 @@ export interface ChannelInfo {
|
|
|
31
32
|
/** The container it is sending, e.g. webm from a browser, flv over RTMP. */
|
|
32
33
|
format: string;
|
|
33
34
|
/** How it arrived. */
|
|
34
|
-
via: "http" | "rtmp";
|
|
35
|
+
via: "http" | "rtmp" | "pull";
|
|
35
36
|
startedAt: number;
|
|
36
37
|
bytes: number;
|
|
37
38
|
listeners: number;
|
|
39
|
+
/**
|
|
40
|
+
* Whether there is a picture, which decides what a listener is sent and
|
|
41
|
+
* what the response calls it. A channel that says audio/mpeg while sending
|
|
42
|
+
* MP4 plays as nothing at all.
|
|
43
|
+
*/
|
|
44
|
+
kind?: "audio" | "video";
|
|
45
|
+
/** For a channel we pull ourselves: where from. Never shown to a listener. */
|
|
46
|
+
source?: string;
|
|
47
|
+
/** The last thing ffmpeg complained about, for whoever administers this. */
|
|
48
|
+
error?: string;
|
|
49
|
+
/** How many times the source has been dialled again since it started. */
|
|
50
|
+
redials?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** How long to wait before dialling a dropped source again. */
|
|
54
|
+
export const REDIAL = 2000;
|
|
55
|
+
/** How many times in a row a source may fail without ever sending anything. */
|
|
56
|
+
export const GIVE_UP = 5;
|
|
57
|
+
/**
|
|
58
|
+
* How long a pulled source may say nothing before it is treated as gone.
|
|
59
|
+
*
|
|
60
|
+
* ffmpeg's own reconnect covers a connection that errors. It does not cover
|
|
61
|
+
* one that simply stops sending, and neither does anything else: a television
|
|
62
|
+
* channel that is quiet for half a minute is not being quiet, it is dead.
|
|
63
|
+
*/
|
|
64
|
+
export const STALL = 30_000;
|
|
65
|
+
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
66
|
+
const TAIL = 2000;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read everything a child says on stderr, keeping only the end of it.
|
|
70
|
+
*
|
|
71
|
+
* This is not optional. A pipe nobody reads fills, at 64 KiB on Linux, and
|
|
72
|
+
* the child then blocks on its next write to it -- every thread it has waits
|
|
73
|
+
* on the one that is stuck, and it produces nothing more, for ever, without
|
|
74
|
+
* exiting. A channel carrying an IPTV transport stream logs a line for every
|
|
75
|
+
* corrupt packet, and over twelve hours that is more than 64 KiB. Measured on
|
|
76
|
+
* the real server: CNN "on the air" with a full stderr socket, its decoder
|
|
77
|
+
* thread asleep in the kernel on that write, its byte count frozen, and a
|
|
78
|
+
* listener handed the opening boxes and then nothing at all.
|
|
79
|
+
*/
|
|
80
|
+
function drain(stream: Readable | null | undefined, keep: (tail: string) => void): void {
|
|
81
|
+
let tail = "";
|
|
82
|
+
stream?.on("data", (chunk: Buffer) => {
|
|
83
|
+
tail = (tail + chunk.toString("utf8")).slice(-TAIL);
|
|
84
|
+
keep(tail);
|
|
85
|
+
});
|
|
86
|
+
stream?.on("error", () => undefined);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The last thing ffmpeg said, which is where it says what went wrong. */
|
|
90
|
+
function lastLine(tail: string): string {
|
|
91
|
+
return tail.trim().split("\n").pop() ?? "";
|
|
38
92
|
}
|
|
39
93
|
|
|
40
94
|
/** A name that can sit in a URL and be read back in a list. */
|
|
@@ -60,6 +114,16 @@ export class Channel {
|
|
|
60
114
|
readonly listeners = new Set<Listener>();
|
|
61
115
|
private child: ChildProcess | null = null;
|
|
62
116
|
private closing = false;
|
|
117
|
+
/** Set for a channel that carries pictures, which cannot be joined blind. */
|
|
118
|
+
private fragments: Fragments | null = null;
|
|
119
|
+
/** For a pulled channel: what to run, and how many times it has failed. */
|
|
120
|
+
private redial: (() => void) | null = null;
|
|
121
|
+
private failures = 0;
|
|
122
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
123
|
+
/** Fires when a pulled source has said nothing for STALL. */
|
|
124
|
+
private watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
125
|
+
private stall = STALL;
|
|
126
|
+
private stderr = "";
|
|
63
127
|
|
|
64
128
|
constructor(
|
|
65
129
|
readonly info: ChannelInfo,
|
|
@@ -92,6 +156,7 @@ export class Channel {
|
|
|
92
156
|
this.info.bytes += chunk.byteLength;
|
|
93
157
|
this.send(chunk);
|
|
94
158
|
});
|
|
159
|
+
drain(child.stderr, (tail) => { this.stderr = tail; });
|
|
95
160
|
// A publisher that hangs up mid-write breaks the pipe, and an unhandled
|
|
96
161
|
// EPIPE takes the whole server with it.
|
|
97
162
|
child.stdin?.on("error", () => this.close());
|
|
@@ -103,6 +168,192 @@ export class Channel {
|
|
|
103
168
|
this.options.onStart?.(this.info);
|
|
104
169
|
}
|
|
105
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Fetch a source ourselves, rather than waiting to be sent one.
|
|
173
|
+
*
|
|
174
|
+
* This is what makes a re-stream a channel instead of a track. A track is
|
|
175
|
+
* played by the one player a server has, so a second one is a second thing
|
|
176
|
+
* that server cannot do at the same time; a channel is its own process with
|
|
177
|
+
* its own audience, and a server can carry as many as it can decode. Two
|
|
178
|
+
* channels means two tabs, or two panels of a multiview.
|
|
179
|
+
*
|
|
180
|
+
* It keeps running with nobody listening. Live television does not pause
|
|
181
|
+
* because you looked away, and a room where the picture depends on who is
|
|
182
|
+
* in it is not a room anybody can be invited to.
|
|
183
|
+
*/
|
|
184
|
+
pull(source: string, encode: string[], paced = true, stall = STALL): void {
|
|
185
|
+
this.stall = stall;
|
|
186
|
+
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
187
|
+
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
188
|
+
const remote = /^https?:\/\//i.test(source);
|
|
189
|
+
|
|
190
|
+
const dial = (): void => {
|
|
191
|
+
if (this.closing) return;
|
|
192
|
+
this.stderr = "";
|
|
193
|
+
const child = spawn(
|
|
194
|
+
command,
|
|
195
|
+
[
|
|
196
|
+
...prefix,
|
|
197
|
+
"-hide_banner",
|
|
198
|
+
"-loglevel", "error",
|
|
199
|
+
// A dropped source is normal over hours, and a channel that dies
|
|
200
|
+
// the first time a CDN hiccups is not a channel anybody can rely
|
|
201
|
+
// on. ffmpeg redials on its own before we have to.
|
|
202
|
+
...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
203
|
+
// A connection that stops answering is an error after this long,
|
|
204
|
+
// and an error is a thing the reconnect above knows what to do
|
|
205
|
+
// with. Without it a silent socket is waited on for ever. In
|
|
206
|
+
// microseconds, as ffmpeg wants it.
|
|
207
|
+
...(remote ? ["-rw_timeout", String(stall * 1000)] : []),
|
|
208
|
+
// Real time, always. A file read as fast as the disk allows is an
|
|
209
|
+
// hour of film in ninety seconds and a room that cannot be in it
|
|
210
|
+
// together; a live source is already paced and loses nothing.
|
|
211
|
+
...(paced ? ["-re"] : []),
|
|
212
|
+
"-i", source,
|
|
213
|
+
...encode,
|
|
214
|
+
"pipe:1",
|
|
215
|
+
],
|
|
216
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
let sent = false;
|
|
220
|
+
this.child = child;
|
|
221
|
+
this.rearm(child);
|
|
222
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
223
|
+
// An ffmpeg that was replaced can still have a chunk in the pipe.
|
|
224
|
+
if (this.child !== child) return;
|
|
225
|
+
sent = true;
|
|
226
|
+
this.info.bytes += chunk.byteLength;
|
|
227
|
+
this.rearm(child);
|
|
228
|
+
this.emit(chunk);
|
|
229
|
+
});
|
|
230
|
+
child.stdout?.on("error", () => undefined);
|
|
231
|
+
drain(child.stderr, (tail) => { this.stderr = tail; });
|
|
232
|
+
// Only the ffmpeg we are currently running gets to say the source
|
|
233
|
+
// dropped. One that was killed to make way for a restart is not news.
|
|
234
|
+
child.on("error", () => { if (this.child === child) this.dropped(sent); });
|
|
235
|
+
child.on("close", () => { if (this.child === child) this.dropped(sent); });
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
this.redial = dial;
|
|
239
|
+
dial();
|
|
240
|
+
this.options.onStart?.(this.info);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Start the source over, now.
|
|
245
|
+
*
|
|
246
|
+
* For a pulled channel only: a publisher's stream cannot be dialled again
|
|
247
|
+
* from this end. The current ffmpeg is killed and a new one started at
|
|
248
|
+
* once, with the count of failures cleared -- somebody asking for this has
|
|
249
|
+
* decided the thing is worth another go, and should not inherit the four
|
|
250
|
+
* strikes a dead CDN ran up an hour ago.
|
|
251
|
+
*/
|
|
252
|
+
restart(): boolean {
|
|
253
|
+
const dial = this.redial;
|
|
254
|
+
if (!dial || this.closing) return false;
|
|
255
|
+
if (this.timer) clearTimeout(this.timer);
|
|
256
|
+
this.timer = null;
|
|
257
|
+
if (this.watchdog) clearTimeout(this.watchdog);
|
|
258
|
+
this.watchdog = null;
|
|
259
|
+
this.failures = 0;
|
|
260
|
+
this.info.redials = (this.info.redials ?? 0) + 1;
|
|
261
|
+
this.info.error = undefined;
|
|
262
|
+
const old = this.child;
|
|
263
|
+
this.child = null;
|
|
264
|
+
old?.kill("SIGKILL");
|
|
265
|
+
this.startOver();
|
|
266
|
+
dial();
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The stream that was is over; the next ffmpeg is a new one.
|
|
272
|
+
*
|
|
273
|
+
* New opening boxes, timestamps from zero again. Whoever was listening
|
|
274
|
+
* cannot follow that mid-picture, and a newcomer must not be handed the old
|
|
275
|
+
* opening boxes in front of the new fragments -- so the header is dropped
|
|
276
|
+
* and the audience is ended, to come back to the stream as it now is. The
|
|
277
|
+
* player rejoins on its own. Done the moment the source is known to be
|
|
278
|
+
* gone, not when the redial happens: somebody joining in between gets the
|
|
279
|
+
* new beginning as it is written, rather than a stale one first.
|
|
280
|
+
*/
|
|
281
|
+
private startOver(): void {
|
|
282
|
+
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
283
|
+
this.hangUp();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Expect output within STALL, or treat the source as gone and dial again. */
|
|
287
|
+
private rearm(child: ChildProcess): void {
|
|
288
|
+
if (this.watchdog) clearTimeout(this.watchdog);
|
|
289
|
+
this.watchdog = setTimeout(() => {
|
|
290
|
+
this.watchdog = null;
|
|
291
|
+
if (this.child !== child || this.closing) return;
|
|
292
|
+
this.info.error = `no data from the source for ${Math.round(this.stall / 1000)}s`;
|
|
293
|
+
// Its close handler is what dials again.
|
|
294
|
+
child.kill("SIGKILL");
|
|
295
|
+
}, this.stall);
|
|
296
|
+
this.watchdog.unref?.();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** End everybody listening; the stream they were on is over. */
|
|
300
|
+
private hangUp(): void {
|
|
301
|
+
for (const listener of this.listeners) {
|
|
302
|
+
try {
|
|
303
|
+
listener.end();
|
|
304
|
+
} catch {
|
|
305
|
+
// Gone already.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
this.listeners.clear();
|
|
309
|
+
this.info.listeners = 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A source that stopped. Try it again, unless it never worked at all.
|
|
314
|
+
*
|
|
315
|
+
* The difference matters: a channel that ran for six hours and dropped is
|
|
316
|
+
* worth dialling again, and a URL that has never once produced a byte is a
|
|
317
|
+
* mistake somebody made, and retrying it for ever helps nobody.
|
|
318
|
+
*/
|
|
319
|
+
private dropped(sent: boolean): void {
|
|
320
|
+
if (this.closing || !this.redial) return;
|
|
321
|
+
this.child = null;
|
|
322
|
+
if (this.watchdog) clearTimeout(this.watchdog);
|
|
323
|
+
this.watchdog = null;
|
|
324
|
+
const said = lastLine(this.stderr);
|
|
325
|
+
if (said) this.info.error = said;
|
|
326
|
+
this.failures = sent ? 0 : this.failures + 1;
|
|
327
|
+
if (this.failures >= GIVE_UP) {
|
|
328
|
+
this.close();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
this.info.redials = (this.info.redials ?? 0) + 1;
|
|
332
|
+
this.startOver();
|
|
333
|
+
const dial = this.redial;
|
|
334
|
+
this.timer = setTimeout(() => {
|
|
335
|
+
this.timer = null;
|
|
336
|
+
dial();
|
|
337
|
+
}, REDIAL);
|
|
338
|
+
// A redial is not a reason to keep the process alive at exit.
|
|
339
|
+
this.timer.unref?.();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Out to the audience, whole boxes at a time when there are boxes.
|
|
344
|
+
*
|
|
345
|
+
* Video listeners are only ever sent complete boxes, so that a new one can
|
|
346
|
+
* be given the opening boxes and then join at the next fragment and have it
|
|
347
|
+
* make sense.
|
|
348
|
+
*/
|
|
349
|
+
private emit(chunk: Buffer): void {
|
|
350
|
+
if (!this.fragments) {
|
|
351
|
+
this.send(chunk);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
for (const box of this.fragments.push(chunk)) this.send(box);
|
|
355
|
+
}
|
|
356
|
+
|
|
106
357
|
/** Feed the source. */
|
|
107
358
|
write(chunk: Buffer): boolean {
|
|
108
359
|
return this.child?.stdin?.write(chunk) ?? false;
|
|
@@ -141,6 +392,16 @@ export class Channel {
|
|
|
141
392
|
}
|
|
142
393
|
|
|
143
394
|
listen(listener: Listener): () => void {
|
|
395
|
+
// What the stream is, before any of what it is currently saying. Without
|
|
396
|
+
// this a listener who arrives after the first second gets fragments that
|
|
397
|
+
// reference tracks they were never told about: a blank panel, no error.
|
|
398
|
+
if (this.fragments?.ready) {
|
|
399
|
+
try {
|
|
400
|
+
listener.write(this.fragments.header);
|
|
401
|
+
} catch {
|
|
402
|
+
// Gone before it began; the detach below still tidies up.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
144
405
|
this.listeners.add(listener);
|
|
145
406
|
this.info.listeners = this.listeners.size;
|
|
146
407
|
return () => {
|
|
@@ -152,6 +413,13 @@ export class Channel {
|
|
|
152
413
|
close(): void {
|
|
153
414
|
if (this.closing) return;
|
|
154
415
|
this.closing = true;
|
|
416
|
+
this.redial = null;
|
|
417
|
+
if (this.timer) clearTimeout(this.timer);
|
|
418
|
+
this.timer = null;
|
|
419
|
+
if (this.watchdog) clearTimeout(this.watchdog);
|
|
420
|
+
this.watchdog = null;
|
|
421
|
+
const said = lastLine(this.stderr);
|
|
422
|
+
if (said && !this.info.error) this.info.error = said;
|
|
155
423
|
const child = this.child;
|
|
156
424
|
this.child = null;
|
|
157
425
|
try {
|
|
@@ -234,6 +502,62 @@ export class Channels {
|
|
|
234
502
|
return channel;
|
|
235
503
|
}
|
|
236
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Carry a source of our own: a re-stream, or a film on this disk shown live.
|
|
507
|
+
*
|
|
508
|
+
* Null when that channel is taken, the same as publishing. Everything else
|
|
509
|
+
* about it is the same too, which is the point -- a re-stream stops being a
|
|
510
|
+
* special case and becomes one more thing that is on.
|
|
511
|
+
*/
|
|
512
|
+
pull(
|
|
513
|
+
id: string,
|
|
514
|
+
name: string,
|
|
515
|
+
source: string,
|
|
516
|
+
encode: string[],
|
|
517
|
+
kind: "audio" | "video",
|
|
518
|
+
paced = true,
|
|
519
|
+
stall = STALL,
|
|
520
|
+
): Channel | null {
|
|
521
|
+
if (this.open.has(id)) return null;
|
|
522
|
+
const channel = new Channel(
|
|
523
|
+
{
|
|
524
|
+
id,
|
|
525
|
+
name: name || source,
|
|
526
|
+
format: kind === "video" ? "mp4" : "mp3",
|
|
527
|
+
via: "pull",
|
|
528
|
+
startedAt: Date.now(),
|
|
529
|
+
bytes: 0,
|
|
530
|
+
listeners: 0,
|
|
531
|
+
kind,
|
|
532
|
+
source,
|
|
533
|
+
},
|
|
534
|
+
this.options,
|
|
535
|
+
(gone) => this.open.delete(gone),
|
|
536
|
+
);
|
|
537
|
+
this.open.set(id, channel);
|
|
538
|
+
channel.pull(source, encode, paced, stall);
|
|
539
|
+
return channel;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Dial a pulled channel's source again, now. False for a channel that is
|
|
544
|
+
* not there or is not ours to dial: a publisher's stream restarts at the
|
|
545
|
+
* publisher's end.
|
|
546
|
+
*/
|
|
547
|
+
restart(id: string): boolean {
|
|
548
|
+
return this.open.get(id)?.restart() ?? false;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Whether a channel is one we fetch ourselves, and so can start over. */
|
|
552
|
+
pulled(id: string): boolean {
|
|
553
|
+
return this.open.get(id)?.info.via === "pull";
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** What a listener should be told this channel is. */
|
|
557
|
+
contentType(id: string): string {
|
|
558
|
+
return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
|
|
559
|
+
}
|
|
560
|
+
|
|
237
561
|
/** Attach a listener, or null when nothing is playing on that channel. */
|
|
238
562
|
listen(id: string, listener: Listener): (() => void) | null {
|
|
239
563
|
const channel = this.open.get(id);
|
package/src/fragments.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splitting a fragmented MP4 into the pieces a late arrival needs.
|
|
3
|
+
*
|
|
4
|
+
* MP3 can be joined halfway through because every frame says what it is: a
|
|
5
|
+
* player finds the next frame boundary and carries on. Fragmented MP4 cannot.
|
|
6
|
+
* It opens with an `ftyp` and a `moov` that describe the tracks -- how many,
|
|
7
|
+
* which codecs, what timescale -- and everything after that is a `moof` and an
|
|
8
|
+
* `mdat` that mean nothing without them. Hand somebody the middle of that
|
|
9
|
+
* stream and their browser has no idea what it is holding, which is a black
|
|
10
|
+
* panel and no error.
|
|
11
|
+
*
|
|
12
|
+
* So the opening boxes are kept, and a listener who arrives an hour late is
|
|
13
|
+
* given them before the live bytes. Fragments written with `frag_keyframe`
|
|
14
|
+
* each begin at a keyframe, so the picture starts at the first one rather than
|
|
15
|
+
* with a screen of blocks catching up.
|
|
16
|
+
*
|
|
17
|
+
* This also means listeners are only ever written whole boxes. A chunk from a
|
|
18
|
+
* pipe ends wherever the pipe felt like ending it, and half a `moof` is not
|
|
19
|
+
* something to send anybody.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The header of an MP4 box: four bytes of length, four of name. */
|
|
23
|
+
const HEADER = 8;
|
|
24
|
+
/** A length of 1 means the real one is the eight bytes that follow. */
|
|
25
|
+
const BIG = 16;
|
|
26
|
+
|
|
27
|
+
export interface Box {
|
|
28
|
+
type: string;
|
|
29
|
+
bytes: Buffer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The first whole box in a buffer, or null when it has not all arrived.
|
|
34
|
+
*
|
|
35
|
+
* Null is also the answer for anything malformed, because the difference does
|
|
36
|
+
* not matter to a caller who can only wait or give up, and guessing at a
|
|
37
|
+
* broken length walks off the end of the stream.
|
|
38
|
+
*/
|
|
39
|
+
export function firstBox(buffer: Buffer): { box: Box; rest: Buffer } | null {
|
|
40
|
+
if (buffer.length < HEADER) return null;
|
|
41
|
+
const stated = buffer.readUInt32BE(0);
|
|
42
|
+
const type = buffer.toString("latin1", 4, HEADER);
|
|
43
|
+
// A name is four printable characters. Anything else means we are not
|
|
44
|
+
// looking at a box header, and no length read from here can be trusted.
|
|
45
|
+
if (!/^[\x20-\x7e]{4}$/.test(type)) return null;
|
|
46
|
+
|
|
47
|
+
let size = stated;
|
|
48
|
+
let header = HEADER;
|
|
49
|
+
if (stated === 1) {
|
|
50
|
+
if (buffer.length < BIG) return null;
|
|
51
|
+
const large = buffer.readBigUInt64BE(HEADER);
|
|
52
|
+
if (large > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
|
53
|
+
size = Number(large);
|
|
54
|
+
header = BIG;
|
|
55
|
+
}
|
|
56
|
+
// Zero means "to the end of the file", which a live stream does not have.
|
|
57
|
+
if (size < header) return null;
|
|
58
|
+
if (buffer.length < size) return null;
|
|
59
|
+
return { box: { type, bytes: buffer.subarray(0, size) }, rest: buffer.subarray(size) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The boxes that describe the stream rather than carry it. */
|
|
63
|
+
export function isOpening(type: string): boolean {
|
|
64
|
+
return type === "ftyp" || type === "moov";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A fragmented MP4 arriving in pieces, handed back a box at a time.
|
|
69
|
+
*
|
|
70
|
+
* Keeps the opening boxes so they can be replayed to whoever turns up later.
|
|
71
|
+
* If the bytes turn out not to be an MP4 at all -- a source that failed, a
|
|
72
|
+
* format nobody expected -- it stops trying to parse and passes them through,
|
|
73
|
+
* on the grounds that a stream somebody might be able to play beats a stream
|
|
74
|
+
* nobody can.
|
|
75
|
+
*/
|
|
76
|
+
export class Fragments {
|
|
77
|
+
private held: Buffer = Buffer.alloc(0);
|
|
78
|
+
private opening: Buffer[] = [];
|
|
79
|
+
private confused = false;
|
|
80
|
+
|
|
81
|
+
/** The `ftyp` and `moov` seen so far, ready to send to a new listener. */
|
|
82
|
+
get header(): Buffer {
|
|
83
|
+
return this.opening.length === 0 ? Buffer.alloc(0) : Buffer.concat(this.opening);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether enough has arrived to describe the stream to somebody new. */
|
|
87
|
+
get ready(): boolean {
|
|
88
|
+
return this.opening.length > 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Feed bytes in; get whole boxes out, in order. */
|
|
92
|
+
push(chunk: Buffer): Buffer[] {
|
|
93
|
+
if (this.confused) return [chunk];
|
|
94
|
+
this.held = this.held.length === 0 ? chunk : Buffer.concat([this.held, chunk]);
|
|
95
|
+
|
|
96
|
+
const out: Buffer[] = [];
|
|
97
|
+
for (;;) {
|
|
98
|
+
const next = firstBox(this.held);
|
|
99
|
+
if (!next) break;
|
|
100
|
+
this.held = next.rest;
|
|
101
|
+
if (isOpening(next.box.type)) this.opening.push(next.box.bytes);
|
|
102
|
+
out.push(next.box.bytes);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Nothing parses and the buffer keeps growing: this is not an MP4. Let it
|
|
106
|
+
// through rather than swallowing a stream into memory for ever.
|
|
107
|
+
if (this.opening.length === 0 && this.held.length > 4 * 1024 * 1024) {
|
|
108
|
+
this.confused = true;
|
|
109
|
+
const everything = this.held;
|
|
110
|
+
this.held = Buffer.alloc(0);
|
|
111
|
+
return [everything];
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/rtmp-in.ts
CHANGED
|
@@ -71,6 +71,10 @@ export class RtmpListeners {
|
|
|
71
71
|
channel?.feed(chunk);
|
|
72
72
|
});
|
|
73
73
|
child.stdout?.on("error", () => child.kill("SIGKILL"));
|
|
74
|
+
// Read and dropped. A pipe nobody reads fills at 64 KiB, and ffmpeg then
|
|
75
|
+
// blocks on its next complaint and stops producing anything -- a
|
|
76
|
+
// publisher whose stream hiccups enough would take the slot down with it.
|
|
77
|
+
child.stderr?.resume();
|
|
74
78
|
child.on("error", () => this.done(slot, child, channel));
|
|
75
79
|
child.on("close", () => this.done(slot, child, channel));
|
|
76
80
|
}
|