nixamp 0.7.36 → 0.7.38
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/channels.d.ts +41 -0
- package/dist/channels.js +96 -2
- package/dist/server.d.ts +11 -1
- package/dist/server.js +47 -10
- package/package.json +1 -1
- package/src/channels.ts +117 -2
- package/src/server.ts +61 -11
- package/web/dist/install.sh +29 -2
- package/web/dist/sw.js +1 -1
package/dist/channels.d.ts
CHANGED
|
@@ -40,6 +40,13 @@ export declare const GIVE_UP = 5;
|
|
|
40
40
|
* channel that is quiet for half a minute is not being quiet, it is dead.
|
|
41
41
|
*/
|
|
42
42
|
export declare const STALL = 30000;
|
|
43
|
+
/**
|
|
44
|
+
* How much of the recent stream a newcomer is handed. About six seconds of
|
|
45
|
+
* 720p television, and a couple of seconds of 192k MP3: enough to play
|
|
46
|
+
* through a hiccup, not enough to put a viewer noticeably behind the room.
|
|
47
|
+
*/
|
|
48
|
+
export declare const BACKLOG_VIDEO: number;
|
|
49
|
+
export declare const BACKLOG_AUDIO: number;
|
|
43
50
|
/** A name that can sit in a URL and be read back in a list. */
|
|
44
51
|
export declare function cleanId(value: unknown, fallback?: string): string;
|
|
45
52
|
export interface ChannelOptions {
|
|
@@ -70,6 +77,19 @@ export declare class Channel {
|
|
|
70
77
|
private watchdog;
|
|
71
78
|
private stall;
|
|
72
79
|
private stderr;
|
|
80
|
+
/**
|
|
81
|
+
* The last few seconds, for whoever joins next.
|
|
82
|
+
*
|
|
83
|
+
* A listener handed only what comes after they arrive starts exactly on the
|
|
84
|
+
* live edge, with nothing buffered ahead: every hiccup in the source or the
|
|
85
|
+
* network is a stall, and CNN in a browser was play, wait, play, wait, for
|
|
86
|
+
* ever. A few seconds of recent fragments, written before the live bytes,
|
|
87
|
+
* is the cushion every other live player has. For a picture the backlog
|
|
88
|
+
* starts at a fragment boundary, because a fragment is the unit a decoder
|
|
89
|
+
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
90
|
+
*/
|
|
91
|
+
private recent;
|
|
92
|
+
private recentBytes;
|
|
73
93
|
constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
|
|
74
94
|
start(format: string): void;
|
|
75
95
|
/**
|
|
@@ -128,6 +148,8 @@ export declare class Channel {
|
|
|
128
148
|
* make sense.
|
|
129
149
|
*/
|
|
130
150
|
private emit;
|
|
151
|
+
/** Keep this for the next arrival, and let the oldest go once it is too much. */
|
|
152
|
+
private remember;
|
|
131
153
|
/** Feed the source. */
|
|
132
154
|
write(chunk: Buffer): boolean;
|
|
133
155
|
pump(body: Readable): Promise<void>;
|
|
@@ -198,5 +220,24 @@ export declare class Channels {
|
|
|
198
220
|
stop(id: string): boolean;
|
|
199
221
|
stopAll(): void;
|
|
200
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* The channels a server pulls itself, remembered across a restart.
|
|
225
|
+
*
|
|
226
|
+
* A server is restarted to pick up a new version, which is to say often, and
|
|
227
|
+
* every restart used to take CNN off the air until somebody noticed and put
|
|
228
|
+
* it back by hand. A publisher's stream cannot be remembered -- it restarts at
|
|
229
|
+
* the publisher's end -- but a pulled one is a name and a URL, and a name and
|
|
230
|
+
* a URL can be written down.
|
|
231
|
+
*
|
|
232
|
+
* Keyed by port, like the keys, because two servers on one machine are two
|
|
233
|
+
* different line-ups.
|
|
234
|
+
*/
|
|
235
|
+
export interface RememberedChannel {
|
|
236
|
+
id: string;
|
|
237
|
+
name: string;
|
|
238
|
+
source: string;
|
|
239
|
+
}
|
|
240
|
+
export declare function rememberedChannels(dir: string, port: number): RememberedChannel[];
|
|
241
|
+
export declare function rememberChannels(dir: string, port: number, list: RememberedChannel[]): void;
|
|
201
242
|
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
202
243
|
export declare function generatedId(): string;
|
package/dist/channels.js
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { randomBytes } from "node:crypto";
|
|
19
|
-
import {
|
|
19
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { Fragments, isOpening } 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. */
|
|
@@ -31,6 +33,17 @@ export const GIVE_UP = 5;
|
|
|
31
33
|
export const STALL = 30_000;
|
|
32
34
|
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
33
35
|
const TAIL = 2000;
|
|
36
|
+
/**
|
|
37
|
+
* How much of the recent stream a newcomer is handed. About six seconds of
|
|
38
|
+
* 720p television, and a couple of seconds of 192k MP3: enough to play
|
|
39
|
+
* through a hiccup, not enough to put a viewer noticeably behind the room.
|
|
40
|
+
*/
|
|
41
|
+
export const BACKLOG_VIDEO = 4 * 1024 * 1024;
|
|
42
|
+
export const BACKLOG_AUDIO = 64 * 1024;
|
|
43
|
+
/** The four-letter name in a box header, or "" for something too short. */
|
|
44
|
+
function boxType(box) {
|
|
45
|
+
return box.length >= 8 ? box.toString("latin1", 4, 8) : "";
|
|
46
|
+
}
|
|
34
47
|
/**
|
|
35
48
|
* Read everything a child says on stderr, keeping only the end of it.
|
|
36
49
|
*
|
|
@@ -85,6 +98,19 @@ export class Channel {
|
|
|
85
98
|
watchdog = null;
|
|
86
99
|
stall = STALL;
|
|
87
100
|
stderr = "";
|
|
101
|
+
/**
|
|
102
|
+
* The last few seconds, for whoever joins next.
|
|
103
|
+
*
|
|
104
|
+
* A listener handed only what comes after they arrive starts exactly on the
|
|
105
|
+
* live edge, with nothing buffered ahead: every hiccup in the source or the
|
|
106
|
+
* network is a stall, and CNN in a browser was play, wait, play, wait, for
|
|
107
|
+
* ever. A few seconds of recent fragments, written before the live bytes,
|
|
108
|
+
* is the cushion every other live player has. For a picture the backlog
|
|
109
|
+
* starts at a fragment boundary, because a fragment is the unit a decoder
|
|
110
|
+
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
111
|
+
*/
|
|
112
|
+
recent = [];
|
|
113
|
+
recentBytes = 0;
|
|
88
114
|
constructor(info, options, onGone) {
|
|
89
115
|
this.info = info;
|
|
90
116
|
this.options = options;
|
|
@@ -232,6 +258,8 @@ export class Channel {
|
|
|
232
258
|
startOver() {
|
|
233
259
|
if (this.info.kind === "video")
|
|
234
260
|
this.fragments = new Fragments();
|
|
261
|
+
this.recent = [];
|
|
262
|
+
this.recentBytes = 0;
|
|
235
263
|
this.hangUp();
|
|
236
264
|
}
|
|
237
265
|
/** Expect output within STALL, or treat the source as gone and dial again. */
|
|
@@ -302,11 +330,32 @@ export class Channel {
|
|
|
302
330
|
*/
|
|
303
331
|
emit(chunk) {
|
|
304
332
|
if (!this.fragments) {
|
|
333
|
+
this.remember(chunk, BACKLOG_AUDIO, false);
|
|
305
334
|
this.send(chunk);
|
|
306
335
|
return;
|
|
307
336
|
}
|
|
308
|
-
for (const box of this.fragments.push(chunk))
|
|
337
|
+
for (const box of this.fragments.push(chunk)) {
|
|
338
|
+
if (!isOpening(boxType(box)))
|
|
339
|
+
this.remember(box, BACKLOG_VIDEO, true);
|
|
309
340
|
this.send(box);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Keep this for the next arrival, and let the oldest go once it is too much. */
|
|
344
|
+
remember(piece, cap, aligned) {
|
|
345
|
+
this.recent.push(piece);
|
|
346
|
+
this.recentBytes += piece.byteLength;
|
|
347
|
+
while (this.recent.length > 0 && this.recentBytes > cap) {
|
|
348
|
+
const gone = this.recent.shift();
|
|
349
|
+
this.recentBytes -= gone.byteLength;
|
|
350
|
+
}
|
|
351
|
+
// A picture's backlog must begin at a `moof`: an `mdat` on its own is
|
|
352
|
+
// samples nobody has been told the layout of.
|
|
353
|
+
if (aligned) {
|
|
354
|
+
while (this.recent.length > 0 && boxType(this.recent[0]) !== "moof") {
|
|
355
|
+
const gone = this.recent.shift();
|
|
356
|
+
this.recentBytes -= gone.byteLength;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
310
359
|
}
|
|
311
360
|
/** Feed the source. */
|
|
312
361
|
write(chunk) {
|
|
@@ -355,6 +404,18 @@ export class Channel {
|
|
|
355
404
|
// Gone before it began; the detach below still tidies up.
|
|
356
405
|
}
|
|
357
406
|
}
|
|
407
|
+
// Then the last few seconds, so there is something to play while the
|
|
408
|
+
// live bytes catch up, rather than a picture that stalls on every hiccup.
|
|
409
|
+
if (!this.fragments || this.fragments.ready) {
|
|
410
|
+
for (const piece of this.recent) {
|
|
411
|
+
try {
|
|
412
|
+
listener.write(piece);
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
358
419
|
this.listeners.add(listener);
|
|
359
420
|
this.info.listeners = this.listeners.size;
|
|
360
421
|
return () => {
|
|
@@ -529,6 +590,39 @@ export class Channels {
|
|
|
529
590
|
channel.close();
|
|
530
591
|
}
|
|
531
592
|
}
|
|
593
|
+
const REMEMBERED = "channels.json";
|
|
594
|
+
export function rememberedChannels(dir, port) {
|
|
595
|
+
try {
|
|
596
|
+
const all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8"));
|
|
597
|
+
const list = all[String(port)];
|
|
598
|
+
if (!Array.isArray(list))
|
|
599
|
+
return [];
|
|
600
|
+
return list.filter((one) => typeof one === "object" && one !== null &&
|
|
601
|
+
typeof one.id === "string" &&
|
|
602
|
+
typeof one.name === "string" &&
|
|
603
|
+
typeof one.source === "string");
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
export function rememberChannels(dir, port, list) {
|
|
610
|
+
let all = {};
|
|
611
|
+
try {
|
|
612
|
+
all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8"));
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
// First time, or unreadable: start again rather than refuse to remember.
|
|
616
|
+
}
|
|
617
|
+
all[String(port)] = list;
|
|
618
|
+
try {
|
|
619
|
+
mkdirSync(dir, { recursive: true });
|
|
620
|
+
writeFileSync(join(dir, REMEMBERED), JSON.stringify(all, null, 2));
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
// A state directory that cannot be written costs a memory, not a stream.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
532
626
|
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
533
627
|
export function generatedId() {
|
|
534
628
|
return `s${randomBytes(3).toString("hex")}`;
|
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();
|
|
@@ -1857,6 +1874,13 @@ export function createHandler(engine, options) {
|
|
|
1857
1874
|
// Asked once: the second call would answer false, having just stopped
|
|
1858
1875
|
// the thing it was asking about.
|
|
1859
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
|
+
}
|
|
1860
1884
|
json(response, stopped ? 200 : 404, { ok: stopped });
|
|
1861
1885
|
return;
|
|
1862
1886
|
}
|
|
@@ -1927,19 +1951,20 @@ export function createHandler(engine, options) {
|
|
|
1927
1951
|
json(response, 409, { error: "that channel is already on" });
|
|
1928
1952
|
return;
|
|
1929
1953
|
}
|
|
1930
|
-
const
|
|
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);
|
|
1954
|
+
const channel = await pullChannel(channels, options.ffprobe ?? ["ffprobe"], wanted, called, source);
|
|
1939
1955
|
if (!channel) {
|
|
1940
1956
|
json(response, 409, { error: "that channel is already on" });
|
|
1941
1957
|
return;
|
|
1942
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
|
+
}
|
|
1943
1968
|
json(response, 200, { ok: true, channel: channel.info });
|
|
1944
1969
|
return;
|
|
1945
1970
|
}
|
|
@@ -2810,6 +2835,17 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2810
2835
|
onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
2811
2836
|
onEnd: (info) => console.log(` "${info.id}" stopped.`),
|
|
2812
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
|
+
}
|
|
2813
2849
|
const destinations = parseDestinations(options.rtmp);
|
|
2814
2850
|
const broadcaster = new Broadcaster(tools.ffmpeg);
|
|
2815
2851
|
const ingest = options.ingest
|
|
@@ -2994,6 +3030,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2994
3030
|
media: options.media,
|
|
2995
3031
|
owner,
|
|
2996
3032
|
channels,
|
|
3033
|
+
rememberChannels: remembering,
|
|
2997
3034
|
publishUrls: () => publishUrls,
|
|
2998
3035
|
serverName: options.name || hostname(),
|
|
2999
3036
|
homeSource: root,
|
package/package.json
CHANGED
package/src/channels.ts
CHANGED
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { spawn, type ChildProcess } 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 type { Readable } from "node:stream";
|
|
20
|
-
import { Fragments } from "./fragments.ts";
|
|
22
|
+
import { Fragments, isOpening } from "./fragments.ts";
|
|
21
23
|
|
|
22
24
|
/** Somewhere for a channel's audio to go. A response, in practice. */
|
|
23
25
|
export interface Listener {
|
|
@@ -64,6 +66,18 @@ export const GIVE_UP = 5;
|
|
|
64
66
|
export const STALL = 30_000;
|
|
65
67
|
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
66
68
|
const TAIL = 2000;
|
|
69
|
+
/**
|
|
70
|
+
* How much of the recent stream a newcomer is handed. About six seconds of
|
|
71
|
+
* 720p television, and a couple of seconds of 192k MP3: enough to play
|
|
72
|
+
* through a hiccup, not enough to put a viewer noticeably behind the room.
|
|
73
|
+
*/
|
|
74
|
+
export const BACKLOG_VIDEO = 4 * 1024 * 1024;
|
|
75
|
+
export const BACKLOG_AUDIO = 64 * 1024;
|
|
76
|
+
|
|
77
|
+
/** The four-letter name in a box header, or "" for something too short. */
|
|
78
|
+
function boxType(box: Buffer): string {
|
|
79
|
+
return box.length >= 8 ? box.toString("latin1", 4, 8) : "";
|
|
80
|
+
}
|
|
67
81
|
|
|
68
82
|
/**
|
|
69
83
|
* Read everything a child says on stderr, keeping only the end of it.
|
|
@@ -124,6 +138,19 @@ export class Channel {
|
|
|
124
138
|
private watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
125
139
|
private stall = STALL;
|
|
126
140
|
private stderr = "";
|
|
141
|
+
/**
|
|
142
|
+
* The last few seconds, for whoever joins next.
|
|
143
|
+
*
|
|
144
|
+
* A listener handed only what comes after they arrive starts exactly on the
|
|
145
|
+
* live edge, with nothing buffered ahead: every hiccup in the source or the
|
|
146
|
+
* network is a stall, and CNN in a browser was play, wait, play, wait, for
|
|
147
|
+
* ever. A few seconds of recent fragments, written before the live bytes,
|
|
148
|
+
* is the cushion every other live player has. For a picture the backlog
|
|
149
|
+
* starts at a fragment boundary, because a fragment is the unit a decoder
|
|
150
|
+
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
151
|
+
*/
|
|
152
|
+
private recent: Buffer[] = [];
|
|
153
|
+
private recentBytes = 0;
|
|
127
154
|
|
|
128
155
|
constructor(
|
|
129
156
|
readonly info: ChannelInfo,
|
|
@@ -280,6 +307,8 @@ export class Channel {
|
|
|
280
307
|
*/
|
|
281
308
|
private startOver(): void {
|
|
282
309
|
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
310
|
+
this.recent = [];
|
|
311
|
+
this.recentBytes = 0;
|
|
283
312
|
this.hangUp();
|
|
284
313
|
}
|
|
285
314
|
|
|
@@ -348,10 +377,32 @@ export class Channel {
|
|
|
348
377
|
*/
|
|
349
378
|
private emit(chunk: Buffer): void {
|
|
350
379
|
if (!this.fragments) {
|
|
380
|
+
this.remember(chunk, BACKLOG_AUDIO, false);
|
|
351
381
|
this.send(chunk);
|
|
352
382
|
return;
|
|
353
383
|
}
|
|
354
|
-
for (const box of this.fragments.push(chunk))
|
|
384
|
+
for (const box of this.fragments.push(chunk)) {
|
|
385
|
+
if (!isOpening(boxType(box))) this.remember(box, BACKLOG_VIDEO, true);
|
|
386
|
+
this.send(box);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Keep this for the next arrival, and let the oldest go once it is too much. */
|
|
391
|
+
private remember(piece: Buffer, cap: number, aligned: boolean): void {
|
|
392
|
+
this.recent.push(piece);
|
|
393
|
+
this.recentBytes += piece.byteLength;
|
|
394
|
+
while (this.recent.length > 0 && this.recentBytes > cap) {
|
|
395
|
+
const gone = this.recent.shift() as Buffer;
|
|
396
|
+
this.recentBytes -= gone.byteLength;
|
|
397
|
+
}
|
|
398
|
+
// A picture's backlog must begin at a `moof`: an `mdat` on its own is
|
|
399
|
+
// samples nobody has been told the layout of.
|
|
400
|
+
if (aligned) {
|
|
401
|
+
while (this.recent.length > 0 && boxType(this.recent[0] as Buffer) !== "moof") {
|
|
402
|
+
const gone = this.recent.shift() as Buffer;
|
|
403
|
+
this.recentBytes -= gone.byteLength;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
355
406
|
}
|
|
356
407
|
|
|
357
408
|
/** Feed the source. */
|
|
@@ -402,6 +453,17 @@ export class Channel {
|
|
|
402
453
|
// Gone before it began; the detach below still tidies up.
|
|
403
454
|
}
|
|
404
455
|
}
|
|
456
|
+
// Then the last few seconds, so there is something to play while the
|
|
457
|
+
// live bytes catch up, rather than a picture that stalls on every hiccup.
|
|
458
|
+
if (!this.fragments || this.fragments.ready) {
|
|
459
|
+
for (const piece of this.recent) {
|
|
460
|
+
try {
|
|
461
|
+
listener.write(piece);
|
|
462
|
+
} catch {
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
405
467
|
this.listeners.add(listener);
|
|
406
468
|
this.info.listeners = this.listeners.size;
|
|
407
469
|
return () => {
|
|
@@ -599,6 +661,59 @@ export class Channels {
|
|
|
599
661
|
}
|
|
600
662
|
}
|
|
601
663
|
|
|
664
|
+
/**
|
|
665
|
+
* The channels a server pulls itself, remembered across a restart.
|
|
666
|
+
*
|
|
667
|
+
* A server is restarted to pick up a new version, which is to say often, and
|
|
668
|
+
* every restart used to take CNN off the air until somebody noticed and put
|
|
669
|
+
* it back by hand. A publisher's stream cannot be remembered -- it restarts at
|
|
670
|
+
* the publisher's end -- but a pulled one is a name and a URL, and a name and
|
|
671
|
+
* a URL can be written down.
|
|
672
|
+
*
|
|
673
|
+
* Keyed by port, like the keys, because two servers on one machine are two
|
|
674
|
+
* different line-ups.
|
|
675
|
+
*/
|
|
676
|
+
export interface RememberedChannel {
|
|
677
|
+
id: string;
|
|
678
|
+
name: string;
|
|
679
|
+
source: string;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const REMEMBERED = "channels.json";
|
|
683
|
+
|
|
684
|
+
export function rememberedChannels(dir: string, port: number): RememberedChannel[] {
|
|
685
|
+
try {
|
|
686
|
+
const all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8")) as Record<string, unknown>;
|
|
687
|
+
const list = all[String(port)];
|
|
688
|
+
if (!Array.isArray(list)) return [];
|
|
689
|
+
return list.filter(
|
|
690
|
+
(one): one is RememberedChannel =>
|
|
691
|
+
typeof one === "object" && one !== null &&
|
|
692
|
+
typeof (one as RememberedChannel).id === "string" &&
|
|
693
|
+
typeof (one as RememberedChannel).name === "string" &&
|
|
694
|
+
typeof (one as RememberedChannel).source === "string",
|
|
695
|
+
);
|
|
696
|
+
} catch {
|
|
697
|
+
return [];
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function rememberChannels(dir: string, port: number, list: RememberedChannel[]): void {
|
|
702
|
+
let all: Record<string, unknown> = {};
|
|
703
|
+
try {
|
|
704
|
+
all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8")) as Record<string, unknown>;
|
|
705
|
+
} catch {
|
|
706
|
+
// First time, or unreadable: start again rather than refuse to remember.
|
|
707
|
+
}
|
|
708
|
+
all[String(port)] = list;
|
|
709
|
+
try {
|
|
710
|
+
mkdirSync(dir, { recursive: true });
|
|
711
|
+
writeFileSync(join(dir, REMEMBERED), JSON.stringify(all, null, 2));
|
|
712
|
+
} catch {
|
|
713
|
+
// A state directory that cannot be written costs a memory, not a stream.
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
602
717
|
/** A channel id nobody chose, for a publisher that did not name one. */
|
|
603
718
|
export function generatedId(): string {
|
|
604
719
|
return `s${randomBytes(3).toString("hex")}`;
|
package/src/server.ts
CHANGED
|
@@ -26,7 +26,10 @@ import {
|
|
|
26
26
|
redact,
|
|
27
27
|
} from "./broadcast.ts";
|
|
28
28
|
import { Ingest, normaliseFormat } from "./ingest.ts";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
Channels, cleanId, generatedId, rememberChannels, rememberedChannels,
|
|
31
|
+
type Channel, type RememberedChannel,
|
|
32
|
+
} from "./channels.ts";
|
|
30
33
|
import { RtmpListeners } from "./rtmp-in.ts";
|
|
31
34
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
|
|
32
35
|
import { anonymousHandle, Handles } from "./handles.ts";
|
|
@@ -908,6 +911,30 @@ export class PlayerEngine implements Engine {
|
|
|
908
911
|
* looking at what is on wants "that album from the web", not every track in
|
|
909
912
|
* it. An entry names where to start, so clicking it plays.
|
|
910
913
|
*/
|
|
914
|
+
/**
|
|
915
|
+
* Probe a source and start carrying it as a channel of its own.
|
|
916
|
+
*
|
|
917
|
+
* Shared by the request that puts one on and the boot that puts remembered
|
|
918
|
+
* ones back, so that both agree on what a source is encoded as. Null when
|
|
919
|
+
* that channel id is already on.
|
|
920
|
+
*/
|
|
921
|
+
export async function pullChannel(
|
|
922
|
+
channels: Channels,
|
|
923
|
+
ffprobe: string[],
|
|
924
|
+
id: string,
|
|
925
|
+
name: string,
|
|
926
|
+
source: string,
|
|
927
|
+
): Promise<Channel | null> {
|
|
928
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
|
|
929
|
+
const kind = codecs.video === "" ? "audio" : "video";
|
|
930
|
+
const encode = kind === "video"
|
|
931
|
+
? videoArgs(codecs)
|
|
932
|
+
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
933
|
+
// plays and the thing a listener can join halfway through.
|
|
934
|
+
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
935
|
+
return channels.pull(id, name, source, encode, kind);
|
|
936
|
+
}
|
|
937
|
+
|
|
911
938
|
export function liveOnes(engine: Engine): { name: string; at: number; tracks: number }[] {
|
|
912
939
|
const tracks = engine.snapshot().tracks ?? [];
|
|
913
940
|
const found = new Map<string, { name: string; at: number; tracks: number }>();
|
|
@@ -1065,6 +1092,8 @@ export interface HandlerOptions {
|
|
|
1065
1092
|
ingest?: Ingest;
|
|
1066
1093
|
/** Several live streams at once, each with its own audience. */
|
|
1067
1094
|
channels?: Channels;
|
|
1095
|
+
/** Write down the channels this server pulls, so a restart puts them back. */
|
|
1096
|
+
rememberChannels?: (list: RememberedChannel[]) => void;
|
|
1068
1097
|
/** Live audio going out to RTMP. */
|
|
1069
1098
|
broadcaster?: Broadcaster;
|
|
1070
1099
|
/** Where a broadcast should send, and what it should look like. */
|
|
@@ -2260,6 +2289,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2260
2289
|
// Asked once: the second call would answer false, having just stopped
|
|
2261
2290
|
// the thing it was asking about.
|
|
2262
2291
|
const stopped = channels.stop(id);
|
|
2292
|
+
// Taken off on purpose is forgotten on purpose: it must not come back
|
|
2293
|
+
// at the next restart.
|
|
2294
|
+
if (stopped && options.rememberChannels) {
|
|
2295
|
+
options.rememberChannels(
|
|
2296
|
+
channels.list()
|
|
2297
|
+
.filter((one) => one.via === "pull" && one.source)
|
|
2298
|
+
.map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2263
2301
|
json(response, stopped ? 200 : 404, { ok: stopped });
|
|
2264
2302
|
return;
|
|
2265
2303
|
}
|
|
@@ -2335,20 +2373,20 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2335
2373
|
return;
|
|
2336
2374
|
}
|
|
2337
2375
|
|
|
2338
|
-
const
|
|
2339
|
-
const codecs = await codecsOf({ ffmpeg: [], ffprobe: probe, play: null }, source);
|
|
2340
|
-
const kind = codecs.video === "" ? "audio" : "video";
|
|
2341
|
-
const encode = kind === "video"
|
|
2342
|
-
? videoArgs(codecs)
|
|
2343
|
-
// No picture in it, so none is invented: MP3 is the thing every
|
|
2344
|
-
// browser plays and the thing a listener can join halfway through.
|
|
2345
|
-
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
2346
|
-
|
|
2347
|
-
const channel = channels.pull(wanted, called, source, encode, kind);
|
|
2376
|
+
const channel = await pullChannel(channels, options.ffprobe ?? ["ffprobe"], wanted, called, source);
|
|
2348
2377
|
if (!channel) {
|
|
2349
2378
|
json(response, 409, { error: "that channel is already on" });
|
|
2350
2379
|
return;
|
|
2351
2380
|
}
|
|
2381
|
+
// Written down, so a restart puts it back on the air.
|
|
2382
|
+
if (options.rememberChannels) {
|
|
2383
|
+
options.rememberChannels([
|
|
2384
|
+
...channels.list()
|
|
2385
|
+
.filter((one) => one.via === "pull" && one.source && one.id !== wanted)
|
|
2386
|
+
.map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
|
|
2387
|
+
{ id: wanted, name: called, source },
|
|
2388
|
+
]);
|
|
2389
|
+
}
|
|
2352
2390
|
json(response, 200, { ok: true, channel: channel.info });
|
|
2353
2391
|
return;
|
|
2354
2392
|
}
|
|
@@ -3279,6 +3317,17 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3279
3317
|
onEnd: (info) => console.log(` "${info.id}" stopped.`),
|
|
3280
3318
|
});
|
|
3281
3319
|
|
|
3320
|
+
// The channels this server was carrying when it was last stopped, put back
|
|
3321
|
+
// on. A server is restarted to pick up a new version, which is often, and
|
|
3322
|
+
// every restart used to take CNN off the air until somebody noticed.
|
|
3323
|
+
const remembering = (list: RememberedChannel[]): void => rememberChannels(stateDir(), options.port, list);
|
|
3324
|
+
for (const one of rememberedChannels(stateDir(), options.port)) {
|
|
3325
|
+
console.log(` Putting "${one.id}" (${one.name}) back on the air.`);
|
|
3326
|
+
void pullChannel(channels, tools.ffprobe, one.id, one.name, one.source).then((channel) => {
|
|
3327
|
+
if (!channel) console.log(` "${one.id}" is already on.`);
|
|
3328
|
+
});
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3282
3331
|
const destinations = parseDestinations(options.rtmp);
|
|
3283
3332
|
const broadcaster = new Broadcaster(tools.ffmpeg);
|
|
3284
3333
|
const ingest = options.ingest
|
|
@@ -3479,6 +3528,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3479
3528
|
media: options.media,
|
|
3480
3529
|
owner,
|
|
3481
3530
|
channels,
|
|
3531
|
+
rememberChannels: remembering,
|
|
3482
3532
|
publishUrls: () => publishUrls,
|
|
3483
3533
|
serverName: options.name || hostname(),
|
|
3484
3534
|
homeSource: root,
|
package/web/dist/install.sh
CHANGED
|
@@ -98,6 +98,22 @@ BASE="${NIXAMP_RELEASE_BASE:-https://github.com/$REPO/releases/download/v$VERSIO
|
|
|
98
98
|
SHARE="$PREFIX/share/nixamp"
|
|
99
99
|
BIN="$PREFIX/bin"
|
|
100
100
|
|
|
101
|
+
# --- a daemon already running here --------------------------------------------
|
|
102
|
+
#
|
|
103
|
+
# Noticed now, restarted at the end. An update used to leave the old version
|
|
104
|
+
# running until somebody remembered to restart it, and restarting it by hand
|
|
105
|
+
# from the wrong directory started a server of your home folder over plain
|
|
106
|
+
# http. `nixamp daemon restart` replays the flags the daemon was started with,
|
|
107
|
+
# so the installer runs that rather than guessing at any.
|
|
108
|
+
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/nixamp"
|
|
109
|
+
DAEMON_PID=""
|
|
110
|
+
if [ -r "$STATE_DIR/daemon.json" ]; then
|
|
111
|
+
DAEMON_PID=$(sed -n 's/^[[:space:]]*"pid":[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$STATE_DIR/daemon.json" | head -n 1)
|
|
112
|
+
if [ -n "$DAEMON_PID" ] && ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
|
113
|
+
DAEMON_PID=""
|
|
114
|
+
fi
|
|
115
|
+
fi
|
|
116
|
+
|
|
101
117
|
say "nixamp $VERSION"
|
|
102
118
|
say " platform: $OS-$ARCH"
|
|
103
119
|
say " desktop: $WANT_DESKTOP"
|
|
@@ -219,7 +235,7 @@ else
|
|
|
219
235
|
RUNTIME=""
|
|
220
236
|
|
|
221
237
|
command -v node >/dev/null 2>&1 ||
|
|
222
|
-
say " note: no desktop app was installed, so the CLI needs Node
|
|
238
|
+
say " note: no desktop app was installed, so the CLI needs Node 22.6 or newer, and none is on PATH."
|
|
223
239
|
fi
|
|
224
240
|
|
|
225
241
|
# The shim. Written here rather than shipped, because only the installer knows
|
|
@@ -236,7 +252,7 @@ else
|
|
|
236
252
|
#!/bin/sh
|
|
237
253
|
# nixamp. Written by the installer; \`nixamp uninstall\` removes it.
|
|
238
254
|
command -v node >/dev/null 2>&1 || {
|
|
239
|
-
echo "nixamp: node
|
|
255
|
+
echo "nixamp: node is not on PATH. This install runs on your own Node (22.6 or newer)." >&2
|
|
240
256
|
exit 69
|
|
241
257
|
}
|
|
242
258
|
NIXAMP_HOME="$SHARE" exec node "$CLI_DIR/bin/nixamp.mjs" "\$@"
|
|
@@ -362,6 +378,17 @@ elif [ "$FW_RESULT" = manual ]; then
|
|
|
362
378
|
say " Open it with: $(firewall_command)"
|
|
363
379
|
fi
|
|
364
380
|
|
|
381
|
+
if [ -n "$DAEMON_PID" ]; then
|
|
382
|
+
say ""
|
|
383
|
+
say "A nixamp daemon was running (pid $DAEMON_PID). Restarting it on $VERSION,"
|
|
384
|
+
say "with the flags it was started with..."
|
|
385
|
+
if "$BIN/nixamp" daemon restart; then
|
|
386
|
+
say " Restarted. The channels it was carrying come back on their own."
|
|
387
|
+
else
|
|
388
|
+
say " Could not restart it. Run: nixamp daemon restart"
|
|
389
|
+
fi
|
|
390
|
+
fi
|
|
391
|
+
|
|
365
392
|
case ":$PATH:" in
|
|
366
393
|
*":$BIN:"*)
|
|
367
394
|
say ""
|
package/web/dist/sw.js
CHANGED