nixamp 0.1.0 → 0.2.0

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/src/server.ts CHANGED
@@ -12,13 +12,28 @@
12
12
  import { createReadStream, statSync } from "node:fs";
13
13
  import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
14
14
  import { networkInterfaces } from "node:os";
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { readFileSync } from "node:fs";
17
+ import { Connections, type Kind } from "./connections.ts";
18
+ import { isRemote } from "./sources.ts";
19
+ import {
20
+ elevate,
21
+ firewallInUse,
22
+ keyCookie,
23
+ keyFrom,
24
+ keysMatch,
25
+ newKey,
26
+ portCommands,
27
+ reachableAddresses,
28
+ shareLink,
29
+ } from "./share.ts";
15
30
  import { extname, join, normalize, resolve, sep } from "node:path";
16
31
  import {
17
32
  detectTools, peaks, RATE, Stream, toMono,
18
33
  type Tools, type Track,
19
34
  } from "./audio.ts";
20
35
  import { Analyser, bandEdges, bands, decay } from "./fft.ts";
21
- import { loadPlaylist } from "./playlist.ts";
36
+ import { loadSource } from "./playlist.ts";
22
37
  import {
23
38
  emptySnapshot, parseCommand,
24
39
  type Command, type RemoteTrack, type Snapshot,
@@ -36,6 +51,22 @@ export interface ServeOptions {
36
51
  web: string | null;
37
52
  /** Stream the library's bytes to remotes. Off keeps the audio on this box. */
38
53
  media: boolean;
54
+ /**
55
+ * Require the key from the share link. Off serves to anyone who can reach the
56
+ * port, which is what the public deployment wants and no private one does.
57
+ */
58
+ key: boolean;
59
+ /**
60
+ * Ask the local firewall to let the port through, and put it back on the way
61
+ * out. Off by default because it changes the machine, not just this process.
62
+ */
63
+ openPort: boolean;
64
+ /**
65
+ * Print one JSON line once listening. `nixamp daemon start` reads it rather
66
+ * than sleeping and hoping, so a daemon that failed to bind is reported as
67
+ * failed instead of started.
68
+ */
69
+ announce: boolean;
39
70
  }
40
71
 
41
72
  /**
@@ -48,9 +79,15 @@ export function parseServeArgs(argv: string[]): ServeOptions {
48
79
  const options: ServeOptions = {
49
80
  root: ".",
50
81
  port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
51
- host: "127.0.0.1",
82
+ // Every interface, because a player nobody else can reach is not much of a
83
+ // remote. The key in the link is what makes that safe; --no-key gives up
84
+ // both at once, and --host pins it back to one address.
85
+ host: "0.0.0.0",
52
86
  web: null,
53
87
  media: true,
88
+ key: true,
89
+ openPort: false,
90
+ announce: false,
54
91
  };
55
92
  let sawRoot = false;
56
93
  for (let i = 0; i < argv.length; i++) {
@@ -73,6 +110,12 @@ export function parseServeArgs(argv: string[]): ServeOptions {
73
110
  options.web = value();
74
111
  } else if (arg === "--no-media") {
75
112
  options.media = false;
113
+ } else if (arg === "--no-key") {
114
+ options.key = false;
115
+ } else if (arg === "--open-port") {
116
+ options.openPort = true;
117
+ } else if (arg === "--announce") {
118
+ options.announce = true;
76
119
  } else if (arg.startsWith("-")) {
77
120
  throw new Error(`nixamp serve: unknown option ${arg}`);
78
121
  } else if (!sawRoot) {
@@ -93,6 +136,7 @@ const TYPES: Record<string, string> = {
93
136
  // The installer, so `curl https://nixamp.com/install.sh` is readable rather
94
137
  // than a download prompt.
95
138
  ".sh": "text/x-shellscript; charset=utf-8",
139
+ ".ps1": "text/plain; charset=utf-8",
96
140
  ".svg": "image/svg+xml",
97
141
  ".png": "image/png",
98
142
  ".ico": "image/x-icon",
@@ -175,6 +219,12 @@ export interface Engine {
175
219
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
176
220
  /** Absolute path of a track, or undefined when the index is not one. */
177
221
  trackPath(index: number): string | undefined;
222
+ /**
223
+ * Play something else instead. Re-streaming is the whole reason the admin
224
+ * view exists: point a running server at a URL without restarting it and
225
+ * dropping every listener.
226
+ */
227
+ replace(tracks: Track[], root: string): void;
178
228
  stop(): void;
179
229
  }
180
230
 
@@ -213,8 +263,8 @@ export class PlayerEngine implements Engine {
213
263
  };
214
264
 
215
265
  constructor(
216
- private readonly tracks: Track[],
217
- private readonly root: string,
266
+ private tracks: Track[],
267
+ private root: string,
218
268
  tools: Tools,
219
269
  /** Frames a second pushed to remotes. */
220
270
  private readonly fps = 12,
@@ -364,6 +414,16 @@ export class PlayerEngine implements Engine {
364
414
  }
365
415
  this.listeners.clear();
366
416
  }
417
+
418
+ replace(tracks: Track[], root: string): void {
419
+ this.stop();
420
+ this.tracks = tracks;
421
+ this.root = root;
422
+ this.state.index = 0;
423
+ this.state.position = 0;
424
+ this.state.note = "";
425
+ this.push();
426
+ }
367
427
  }
368
428
 
369
429
  /** An engine with no library behind it, for the hosted PWA. */
@@ -380,6 +440,7 @@ export class EmptyEngine implements Engine {
380
440
  trackPath(): undefined {
381
441
  return undefined;
382
442
  }
443
+ replace(): void {}
383
444
  stop(): void {}
384
445
  }
385
446
 
@@ -420,6 +481,17 @@ export interface HandlerOptions {
420
481
  web: string | null;
421
482
  media: boolean;
422
483
  version: string;
484
+ /** The key from the share link, or null to serve to anyone who can connect. */
485
+ key?: string | null;
486
+ /** How to run ffmpeg, for the sources a browser cannot play by itself. */
487
+ ffmpeg?: string[];
488
+ /** Who is listening, for the admin view. */
489
+ connections?: Connections;
490
+ /**
491
+ * How to turn a source into tracks, for re-streaming. Injected rather than
492
+ * imported so the handler stays a plain function of a request.
493
+ */
494
+ load: (source: string) => Promise<Track[]>;
423
495
  }
424
496
 
425
497
  /**
@@ -427,9 +499,33 @@ export interface HandlerOptions {
427
499
  * drive it with a real socket and no ffmpeg in sight.
428
500
  */
429
501
  export function createHandler(engine: Engine, options: HandlerOptions) {
502
+ const tracker = options.connections ?? new Connections();
503
+ const started = Date.now();
504
+
505
+ /** Count a request in, count its bytes, and close it out exactly once. */
506
+ const watch = (
507
+ request: IncomingMessage,
508
+ response: ServerResponse,
509
+ kind: Kind,
510
+ track: string,
511
+ ): void => {
512
+ const { id } = tracker.open(request, kind, track);
513
+ const write = response.write.bind(response);
514
+ response.write = ((chunk: unknown, ...rest: unknown[]) => {
515
+ if (typeof chunk === "string" || chunk instanceof Uint8Array) {
516
+ tracker.add(id, typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength);
517
+ }
518
+ return (write as (...args: unknown[]) => boolean)(chunk, ...rest);
519
+ }) as typeof response.write;
520
+ // 'close' fires for a finished response and for a listener that walked
521
+ // away, which are the same thing as far as "is it still going" goes.
522
+ response.once("close", () => tracker.close(id));
523
+ };
524
+
430
525
  return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
431
526
  const url = new URL(request.url ?? "/", "http://localhost");
432
527
  const path = url.pathname;
528
+ const key = options.key ?? null;
433
529
 
434
530
  if (request.method === "OPTIONS") {
435
531
  response.writeHead(204, CORS);
@@ -437,6 +533,31 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
437
533
  return;
438
534
  }
439
535
 
536
+ // Opening the share link is what hands a browser its key. It comes back as
537
+ // a cookie, so every later fetch, EventSource and <audio src> carries it
538
+ // without the page knowing anything about keys.
539
+ if (key !== null && path.startsWith("/s/")) {
540
+ const offered = decodeURIComponent(path.slice("/s/".length));
541
+ if (!keysMatch(offered, key)) {
542
+ json(response, 404, { error: "not found" });
543
+ return;
544
+ }
545
+ response.writeHead(302, { ...CORS, "set-cookie": keyCookie(key), location: "/" });
546
+ response.end();
547
+ return;
548
+ }
549
+
550
+ // /api/health answers unauthenticated on purpose: it is how you check the
551
+ // port is open from another device before wondering whether the link is
552
+ // wrong, and it says nothing about the library.
553
+ if (key !== null && path !== "/api/health") {
554
+ const offered = keyFrom(request, url);
555
+ if (offered === null || !keysMatch(offered, key)) {
556
+ json(response, 401, { error: "this nixamp needs the key from its share link" });
557
+ return;
558
+ }
559
+ }
560
+
440
561
  if (path === "/api/health") {
441
562
  json(response, 200, { name: "nixamp", version: options.version, media: options.media });
442
563
  return;
@@ -447,7 +568,20 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
447
568
  return;
448
569
  }
449
570
 
571
+ // Everything the admin view draws, in one request: who is connected, and
572
+ // what this server is.
573
+ if (path === "/api/connections") {
574
+ json(response, 200, {
575
+ connections: tracker.list(),
576
+ active: tracker.active,
577
+ startedAt: started,
578
+ now: Date.now(),
579
+ });
580
+ return;
581
+ }
582
+
450
583
  if (path === "/api/events") {
584
+ watch(request, response, "events", "");
451
585
  response.writeHead(200, {
452
586
  ...CORS,
453
587
  "content-type": "text/event-stream; charset=utf-8",
@@ -494,6 +628,38 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
494
628
  return;
495
629
  }
496
630
 
631
+ // Re-stream: hand the running server a different source. The listeners
632
+ // stay connected; what they are listening to changes under them.
633
+ if (path === "/api/source") {
634
+ if (request.method !== "POST") {
635
+ json(response, 405, { error: "POST only" });
636
+ return;
637
+ }
638
+ let source = "";
639
+ try {
640
+ source = String((JSON.parse(await readBody(request)) as { source?: unknown }).source ?? "");
641
+ } catch {
642
+ json(response, 400, { error: "bad JSON" });
643
+ return;
644
+ }
645
+ if (!source) {
646
+ json(response, 400, { error: "no source given" });
647
+ return;
648
+ }
649
+ try {
650
+ const tracks = await options.load(source);
651
+ if (tracks.length === 0) {
652
+ json(response, 422, { error: `nothing to play at ${source}` });
653
+ return;
654
+ }
655
+ engine.replace(tracks, source);
656
+ json(response, 200, engine.snapshot());
657
+ } catch (error) {
658
+ json(response, 422, { error: (error as Error).message.replace(/^nixamp: /, "") });
659
+ }
660
+ return;
661
+ }
662
+
497
663
  if (path.startsWith("/api/media/")) {
498
664
  if (!options.media) {
499
665
  json(response, 403, { error: "media streaming is off" });
@@ -505,10 +671,31 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
505
671
  json(response, 404, { error: "no such track" });
506
672
  return;
507
673
  }
674
+ watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
508
675
  sendFile(request, response, file);
509
676
  return;
510
677
  }
511
678
 
679
+ // Whatever the source is, this comes back as MP3 a browser will play:
680
+ // a flac, a wma, a URL, an HLS stream. ffmpeg reads them all and we hand
681
+ // the bytes on as they arrive, so a live stream starts immediately rather
682
+ // than after it ends, which for a live stream is never.
683
+ if (path.startsWith("/api/stream/")) {
684
+ const index = Number(path.slice("/api/stream/".length));
685
+ const source = Number.isInteger(index) ? engine.trackPath(index) : undefined;
686
+ if (source === undefined) {
687
+ json(response, 404, { error: "no such track" });
688
+ return;
689
+ }
690
+ if (!options.media) {
691
+ json(response, 403, { error: "media streaming is off" });
692
+ return;
693
+ }
694
+ watch(request, response, "stream", engine.snapshot().tracks[index]?.title ?? source);
695
+ transcode(request, response, source, options.ffmpeg ?? ["ffmpeg"]);
696
+ return;
697
+ }
698
+
512
699
  if (path.startsWith("/api/")) {
513
700
  json(response, 404, { error: "no such endpoint" });
514
701
  return;
@@ -535,6 +722,101 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
535
722
  };
536
723
  }
537
724
 
725
+ /** Read a file, or null. The firewall probe asks about files it may not have. */
726
+ function readIfPossible(path: string): string | null {
727
+ try {
728
+ return readFileSync(path, "utf8");
729
+ } catch {
730
+ return null;
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Decode anything and hand back MP3, as it is produced.
736
+ *
737
+ * No seeking: this is a pipe, and the length is not known until it ends. The
738
+ * player falls back to /api/media for a local file it can seek, and uses this
739
+ * for everything else.
740
+ */
741
+ function transcode(
742
+ request: IncomingMessage,
743
+ response: ServerResponse,
744
+ source: string,
745
+ ffmpeg: string[],
746
+ ): void {
747
+ const [command, ...prefix] = ffmpeg as [string, ...string[]];
748
+ const child = spawn(
749
+ command,
750
+ [
751
+ ...prefix,
752
+ "-hide_banner",
753
+ "-loglevel", "error",
754
+ // Reconnect through the sort of hiccup a long stream runs into. These
755
+ // belong to the http protocol, and ffmpeg rejects the whole command
756
+ // when they are handed to it for a file on disk.
757
+ ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
758
+ "-i", source,
759
+ "-vn",
760
+ "-f", "mp3",
761
+ "-b:a", "192k",
762
+ "-",
763
+ ],
764
+ { stdio: ["ignore", "pipe", "pipe"] },
765
+ );
766
+
767
+ let failed = "";
768
+ child.stderr.on("data", (chunk: Buffer) => {
769
+ // Keep the tail: ffmpeg says what went wrong on its last line.
770
+ failed = (failed + chunk.toString()).slice(-2000);
771
+ });
772
+
773
+ let started = false;
774
+ const begin = (): void => {
775
+ if (started) return;
776
+ started = true;
777
+ response.writeHead(200, {
778
+ ...CORS,
779
+ "content-type": "audio/mpeg",
780
+ "cache-control": "no-store",
781
+ // Length is unknowable up front, and a browser is happy without it.
782
+ "transfer-encoding": "chunked",
783
+ });
784
+ };
785
+ // Wait for a first byte before promising success. ffmpeg rejects a bad option
786
+ // or a missing input immediately, and answering 200 with nothing looks the
787
+ // same from a player as a track that is simply silent.
788
+ child.stdout.once("data", begin);
789
+ // Both ends can fail: a listener closing the tab breaks the socket under the
790
+ // pipe, and an EPIPE nobody is listening for takes the process down.
791
+ child.stdout.on("error", () => child.kill("SIGKILL"));
792
+ response.on("error", () => child.kill("SIGKILL"));
793
+ child.stdout.pipe(response);
794
+
795
+ child.on("error", (error) => {
796
+ console.error(`nixamp: ffmpeg could not start: ${error.message}`);
797
+ if (!response.headersSent) json(response, 500, { error: "ffmpeg could not start" });
798
+ else response.end();
799
+ });
800
+ child.on("close", (code) => {
801
+ const message = failed.trim();
802
+ if (code !== 0 && code !== null) console.error(`nixamp: ffmpeg exited ${code}: ${message}`);
803
+ if (!started) {
804
+ // Nothing was ever produced, so the status can still tell the truth.
805
+ json(response, 502, { error: "could not decode that source", detail: message.split("\n").pop() ?? "" });
806
+ return;
807
+ }
808
+ response.end();
809
+ });
810
+
811
+ // A listener that closes the tab should not leave an ffmpeg decoding into
812
+ // nothing for the rest of the album.
813
+ const stop = (): void => {
814
+ child.kill("SIGKILL");
815
+ };
816
+ request.on("close", stop);
817
+ response.on("close", stop);
818
+ }
819
+
538
820
  function isFile(path: string): boolean {
539
821
  try {
540
822
  return statSync(path).isFile();
@@ -592,38 +874,128 @@ export function createServer(engine: Engine, options: HandlerOptions): Server {
592
874
  });
593
875
  }
594
876
 
595
- /** Where a remote on another device should point its browser. */
596
- export function addressesFor(host: string, port: number): string[] {
597
- if (host !== "0.0.0.0" && host !== "::") return [`http://${host}:${port}`];
598
- const out = [`http://localhost:${port}`];
599
- for (const entries of Object.values(networkInterfaces())) {
600
- for (const entry of entries ?? []) {
601
- if (entry.family === "IPv4" && !entry.internal) out.push(`http://${entry.address}:${port}`);
602
- }
603
- }
604
- return out;
605
- }
606
877
 
607
878
  export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
608
879
  const options = parseServeArgs(argv);
609
- const root = resolve(options.root);
880
+ const root = isRemote(options.root) ? options.root : resolve(options.root);
610
881
  const tools = detectTools();
611
- const tracks = loadPlaylist(tools, root);
882
+ const tracks = await loadSource(tools, root);
612
883
  const engine: Engine = tracks.length > 0
613
884
  ? new PlayerEngine(tracks, root, tools)
614
885
  : new EmptyEngine(`No audio files under ${root}.`);
615
886
 
616
887
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
617
- const server = createServer(engine, { web, media: options.media, version });
888
+ const key = options.key ? newKey() : null;
889
+ const server = createServer(engine, {
890
+ web,
891
+ media: options.media,
892
+ version,
893
+ key,
894
+ ffmpeg: tools.ffmpeg,
895
+ load: (next) => loadSource(tools, next),
896
+ });
618
897
 
619
- await new Promise<void>((done) => server.listen(options.port, options.host, done));
898
+ // A port already in use is the most ordinary failure there is, and it
899
+ // arrives as an unhandled 'error' event that takes the process down with a
900
+ // stack trace nobody reads.
901
+ await new Promise<void>((done, fail) => {
902
+ server.once("error", (error: NodeJS.ErrnoException) => {
903
+ fail(
904
+ new Error(
905
+ error.code === "EADDRINUSE"
906
+ ? `nixamp: port ${options.port} is already in use. Pass --port to pick another.`
907
+ : error.code === "EACCES"
908
+ ? `nixamp: not allowed to listen on port ${options.port}. Ports below 1024 need root.`
909
+ : `nixamp: could not listen on ${options.host}:${options.port}: ${error.message}`,
910
+ ),
911
+ );
912
+ });
913
+ server.listen(options.port, options.host, done);
914
+ });
620
915
  const bound = server.address();
621
916
  const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
917
+
918
+ const io = {
919
+ read: readIfPossible,
920
+ run: (command: string, args: string[]) => {
921
+ const done = spawnSync(command, args, { encoding: "utf8" });
922
+ return { status: done.status, stdout: done.stdout ?? "" };
923
+ },
924
+ };
925
+
926
+ if (options.announce) {
927
+ console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
928
+ }
929
+
622
930
  console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
623
- for (const address of addressesFor(options.host, port)) console.log(` ${address}`);
624
- if (web === null) console.log(" (no built PWA found — run `bun run web:build` to serve one)");
931
+ console.log("");
932
+
933
+ // The link, not the address. Without the key the address is a 401, so
934
+ // printing a bare host:port would be printing something that does not work.
935
+ const addresses = reachableAddresses(options.host, port);
936
+ const width = Math.max(...addresses.map((a) => a.label.length));
937
+ for (const { label, url } of addresses) {
938
+ console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
939
+ }
940
+
941
+ console.log("");
942
+ if (key === null) {
943
+ console.log(" No key: anyone who can reach this port can drive it and hear it.");
944
+ } else {
945
+ console.log(" Open that link once on a phone or a laptop and it stays signed in.");
946
+ console.log(` Anything without the key gets a 401. Key: ${key}`);
947
+ }
948
+ if (addresses.some((a) => a.label === "on the internet")) {
949
+ console.log("");
950
+ console.log(
951
+ key === null
952
+ ? " The public address is open to anyone: --no-key means no key. --host 127.0.0.1 keeps it here."
953
+ : " The public address works from anywhere, for anyone with the key. --host 127.0.0.1 keeps it here.",
954
+ );
955
+ }
956
+ if (!options.media) console.log(" Audio stays on this machine: --no-media is set.");
957
+ if (web === null) console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
958
+
959
+ // Listening on every interface proves the socket is open here and nothing
960
+ // about the path between here and the phone.
961
+ const listening = options.host === "0.0.0.0" || options.host === "::";
962
+ const firewall = listening ? firewallInUse(io) : null;
963
+ let closePort: (() => void) | null = null;
964
+
965
+ if (firewall !== null) {
966
+ const { open, close } = portCommands(firewall, port);
967
+ if (!options.openPort) {
968
+ console.log("");
969
+ console.log(` ${firewall} is running, so other devices cannot reach this port yet:`);
970
+ console.log(` sudo ${open.join(" ")}`);
971
+ console.log(" or start with --open-port and nixamp will do it, and undo it on exit.");
972
+ } else {
973
+ const elevated = elevate(io, open);
974
+ if (elevated === null) {
975
+ console.log("");
976
+ console.log(` --open-port needs root or passwordless sudo. Run this yourself:`);
977
+ console.log(` sudo ${open.join(" ")}`);
978
+ } else {
979
+ const done = spawnSync(elevated[0] as string, elevated.slice(1), { encoding: "utf8" });
980
+ if (done.status === 0) {
981
+ console.log("");
982
+ console.log(` Opened ${port}/tcp in ${firewall}. It closes again when this exits.`);
983
+ // Leave the machine as it was found. A player should not be the
984
+ // reason a port is still open next week.
985
+ closePort = () => {
986
+ const undo = elevate(io, close);
987
+ if (undo) spawnSync(undo[0] as string, undo.slice(1), { stdio: "ignore" });
988
+ };
989
+ } else {
990
+ console.log("");
991
+ console.log(` Could not open the port: ${(done.stderr || done.stdout || "").trim() || "unknown error"}`);
992
+ }
993
+ }
994
+ }
995
+ }
625
996
 
626
997
  const shutdown = (): void => {
998
+ closePort?.();
627
999
  engine.stop();
628
1000
  server.close(() => process.exit(0));
629
1001
  // A hung keep-alive should not outlive a ctrl-c.