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/README.md +54 -0
- package/bin/nixamp.mjs +4 -1
- package/dist/admin.d.ts +47 -0
- package/dist/admin.js +209 -0
- package/dist/connections.d.ts +66 -0
- package/dist/connections.js +115 -0
- package/dist/daemon.d.ts +39 -0
- package/dist/daemon.js +170 -0
- package/dist/main.js +82 -6
- package/dist/manage.js +28 -6
- package/dist/playlist.d.ts +16 -0
- package/dist/playlist.js +57 -2
- package/dist/server.d.ts +38 -4
- package/dist/server.js +323 -23
- package/dist/share.d.ts +58 -0
- package/dist/share.js +153 -0
- package/dist/sources.d.ts +37 -0
- package/dist/sources.js +125 -0
- package/package.json +1 -1
- package/src/admin.ts +243 -0
- package/src/connections.ts +145 -0
- package/src/daemon.ts +193 -0
- package/src/main.ts +86 -6
- package/src/manage.ts +33 -6
- package/src/playlist.ts +68 -2
- package/src/server.ts +393 -21
- package/src/share.ts +166 -0
- package/src/sources.ts +136 -0
- package/web/dist/install.ps1 +214 -0
- package/web/dist/sw.js +1 -1
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 {
|
|
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
|
-
|
|
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
|
|
217
|
-
private
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
|
|
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.
|