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.
@@ -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 { Fragments } from "./fragments.js";
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 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);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.36",
3
+ "version": "0.7.38",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/channels.ts CHANGED
@@ -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)) this.send(box);
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 { Channels, cleanId, generatedId } from "./channels.ts";
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 probe = options.ffprobe ?? ["ffprobe"];
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,
@@ -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 24 or newer. It was not found."
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 24 or newer is required for a CLI-only install." >&2
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
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1789030337931";
2
+ const CACHE = "nixamp-1789031509853";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",