nixamp 0.7.36 → 0.7.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -198,5 +198,24 @@ export declare class Channels {
198
198
  stop(id: string): boolean;
199
199
  stopAll(): void;
200
200
  }
201
+ /**
202
+ * The channels a server pulls itself, remembered across a restart.
203
+ *
204
+ * A server is restarted to pick up a new version, which is to say often, and
205
+ * every restart used to take CNN off the air until somebody noticed and put
206
+ * it back by hand. A publisher's stream cannot be remembered -- it restarts at
207
+ * the publisher's end -- but a pulled one is a name and a URL, and a name and
208
+ * a URL can be written down.
209
+ *
210
+ * Keyed by port, like the keys, because two servers on one machine are two
211
+ * different line-ups.
212
+ */
213
+ export interface RememberedChannel {
214
+ id: string;
215
+ name: string;
216
+ source: string;
217
+ }
218
+ export declare function rememberedChannels(dir: string, port: number): RememberedChannel[];
219
+ export declare function rememberChannels(dir: string, port: number, list: RememberedChannel[]): void;
201
220
  /** A channel id nobody chose, for a publisher that did not name one. */
202
221
  export declare function generatedId(): string;
package/dist/channels.js CHANGED
@@ -16,6 +16,8 @@
16
16
  */
17
17
  import { spawn } from "node:child_process";
18
18
  import { randomBytes } from "node:crypto";
19
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
19
21
  import { Fragments } from "./fragments.js";
20
22
  /** How long to wait before dialling a dropped source again. */
21
23
  export const REDIAL = 2000;
@@ -529,6 +531,39 @@ export class Channels {
529
531
  channel.close();
530
532
  }
531
533
  }
534
+ const REMEMBERED = "channels.json";
535
+ export function rememberedChannels(dir, port) {
536
+ try {
537
+ const all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8"));
538
+ const list = all[String(port)];
539
+ if (!Array.isArray(list))
540
+ return [];
541
+ return list.filter((one) => typeof one === "object" && one !== null &&
542
+ typeof one.id === "string" &&
543
+ typeof one.name === "string" &&
544
+ typeof one.source === "string");
545
+ }
546
+ catch {
547
+ return [];
548
+ }
549
+ }
550
+ export function rememberChannels(dir, port, list) {
551
+ let all = {};
552
+ try {
553
+ all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8"));
554
+ }
555
+ catch {
556
+ // First time, or unreadable: start again rather than refuse to remember.
557
+ }
558
+ all[String(port)] = list;
559
+ try {
560
+ mkdirSync(dir, { recursive: true });
561
+ writeFileSync(join(dir, REMEMBERED), JSON.stringify(all, null, 2));
562
+ }
563
+ catch {
564
+ // A state directory that cannot be written costs a memory, not a stream.
565
+ }
566
+ }
532
567
  /** A channel id nobody chose, for a publisher that did not name one. */
533
568
  export function generatedId() {
534
569
  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.37",
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,6 +16,8 @@
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
22
  import { Fragments } from "./fragments.ts";
21
23
 
@@ -599,6 +601,59 @@ export class Channels {
599
601
  }
600
602
  }
601
603
 
604
+ /**
605
+ * The channels a server pulls itself, remembered across a restart.
606
+ *
607
+ * A server is restarted to pick up a new version, which is to say often, and
608
+ * every restart used to take CNN off the air until somebody noticed and put
609
+ * it back by hand. A publisher's stream cannot be remembered -- it restarts at
610
+ * the publisher's end -- but a pulled one is a name and a URL, and a name and
611
+ * a URL can be written down.
612
+ *
613
+ * Keyed by port, like the keys, because two servers on one machine are two
614
+ * different line-ups.
615
+ */
616
+ export interface RememberedChannel {
617
+ id: string;
618
+ name: string;
619
+ source: string;
620
+ }
621
+
622
+ const REMEMBERED = "channels.json";
623
+
624
+ export function rememberedChannels(dir: string, port: number): RememberedChannel[] {
625
+ try {
626
+ const all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8")) as Record<string, unknown>;
627
+ const list = all[String(port)];
628
+ if (!Array.isArray(list)) return [];
629
+ return list.filter(
630
+ (one): one is RememberedChannel =>
631
+ typeof one === "object" && one !== null &&
632
+ typeof (one as RememberedChannel).id === "string" &&
633
+ typeof (one as RememberedChannel).name === "string" &&
634
+ typeof (one as RememberedChannel).source === "string",
635
+ );
636
+ } catch {
637
+ return [];
638
+ }
639
+ }
640
+
641
+ export function rememberChannels(dir: string, port: number, list: RememberedChannel[]): void {
642
+ let all: Record<string, unknown> = {};
643
+ try {
644
+ all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8")) as Record<string, unknown>;
645
+ } catch {
646
+ // First time, or unreadable: start again rather than refuse to remember.
647
+ }
648
+ all[String(port)] = list;
649
+ try {
650
+ mkdirSync(dir, { recursive: true });
651
+ writeFileSync(join(dir, REMEMBERED), JSON.stringify(all, null, 2));
652
+ } catch {
653
+ // A state directory that cannot be written costs a memory, not a stream.
654
+ }
655
+ }
656
+
602
657
  /** A channel id nobody chose, for a publisher that did not name one. */
603
658
  export function generatedId(): string {
604
659
  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-1789031044115";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",