nixamp 0.5.1 → 0.5.3

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 CHANGED
@@ -268,6 +268,27 @@ Start writes down where it went and the key it minted, waits until the server
268
268
  is actually answering before saying it started, and prints the share link. It is
269
269
  one daemon per user, and the state lives in `$XDG_STATE_HOME/nixamp`.
270
270
 
271
+ ### Somewhere the rest of the world can reach
272
+
273
+ The addresses nixamp prints are the ones its own interfaces have, so a machine
274
+ behind NAT only ever sees `192.168.x` -- no use to anybody else, and nothing it
275
+ can publish. Tell it the address it answers on from outside:
276
+
277
+ ```
278
+ nixamp serve ~/Music --public-url https://nixamp.example.com # or NIXAMP_PUBLIC_URL
279
+ ```
280
+
281
+ That address is what the share links print and what the directory listing
282
+ carries. Getting one is your business, not nixamp's: a forwarded port, a reverse
283
+ proxy, or a tunnel, e.g.
284
+
285
+ ```
286
+ cloudflared tunnel --url http://localhost:8420
287
+ ```
288
+
289
+ Without it, `--publish` is skipped entirely rather than listing a stream nobody
290
+ outside the house can open.
291
+
271
292
  ### Detaching, and coming back
272
293
 
273
294
  `d` in the player hands the music to a daemon and gives you your terminal back.
package/dist/daemon.d.ts CHANGED
@@ -8,6 +8,20 @@ export interface DaemonState {
8
8
  startedAt: number;
9
9
  /** Where its output went, for when it died and you want to know why. */
10
10
  log: string;
11
+ /**
12
+ * The labelled addresses the server itself worked out, share key not applied.
13
+ *
14
+ * Recorded rather than recomputed because only the server knows them: a
15
+ * daemon bound to every interface has no single host to print, and the
16
+ * public one may be a tunnel it was told about rather than an interface
17
+ * anybody here can see. Absent on a state file written by an older nixamp.
18
+ */
19
+ urls?: {
20
+ label: string;
21
+ url: string;
22
+ }[];
23
+ /** The firewall standing between this port and the rest of the network. */
24
+ firewall?: string | null;
11
25
  }
12
26
  /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
13
27
  export declare function stateDir(): string;
@@ -29,6 +43,17 @@ export declare function status(): {
29
43
  };
30
44
  /** The URL an admin client should talk to. */
31
45
  export declare function daemonUrl(state: DaemonState): string;
46
+ /**
47
+ * What `nixamp daemon start` prints, as lines, so it can be tested without
48
+ * starting a daemon.
49
+ *
50
+ * Every address the server found, not one loopback link: the point of a daemon
51
+ * is the phone in the other room, and 127.0.0.1 is the single address that
52
+ * cannot be handed to anybody. The firewall warning comes with it because the
53
+ * server writes that into a log file nobody reads, not to the person who just
54
+ * typed the command.
55
+ */
56
+ export declare function daemonLines(state: DaemonState): string[];
32
57
  /**
33
58
  * Start one, detached, and wait until it is actually answering before saying
34
59
  * it started. Reporting success and leaving the user to discover a crash in a
package/dist/daemon.js CHANGED
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
+ import { portCommands } from "./share.js";
13
14
  /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
14
15
  export function stateDir() {
15
16
  const base = process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
@@ -63,6 +64,36 @@ export function daemonUrl(state) {
63
64
  const host = state.host === "0.0.0.0" || state.host === "::" ? "127.0.0.1" : state.host;
64
65
  return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
65
66
  }
67
+ /**
68
+ * What `nixamp daemon start` prints, as lines, so it can be tested without
69
+ * starting a daemon.
70
+ *
71
+ * Every address the server found, not one loopback link: the point of a daemon
72
+ * is the phone in the other room, and 127.0.0.1 is the single address that
73
+ * cannot be handed to anybody. The firewall warning comes with it because the
74
+ * server writes that into a log file nobody reads, not to the person who just
75
+ * typed the command.
76
+ */
77
+ export function daemonLines(state) {
78
+ const link = (url) => (state.key ? `${url}/s/${state.key}` : url);
79
+ // A state file written by an older nixamp has no list, so host and port
80
+ // still stand in rather than printing nothing at all.
81
+ const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
82
+ const width = Math.max(...addresses.map((a) => a.label.length), "source".length);
83
+ const lines = [`nixamp daemon running (pid ${state.pid})`];
84
+ for (const { label, url } of addresses)
85
+ lines.push(` ${label.padEnd(width)} ${link(url)}`);
86
+ lines.push(` ${"source".padEnd(width)} ${state.source}`);
87
+ if (state.firewall) {
88
+ const { open } = portCommands(state.firewall, state.port);
89
+ lines.push("", ` ${state.firewall} is running, so nothing else can reach port ${state.port} yet:`, ` sudo ${open.join(" ")}`, " or `nixamp daemon stop` and start again with --open-port.");
90
+ }
91
+ if (!addresses.some((a) => a.label === "on the internet")) {
92
+ lines.push("", " None of those work from outside this network. If it should:", " nixamp daemon start ... --public-url https://your-tunnel.example.com");
93
+ }
94
+ lines.push("", " nixamp attach the player, in front of it", " nixamp admin who is connected", " nixamp daemon stop when you are done");
95
+ return lines;
96
+ }
66
97
  /**
67
98
  * Start one, detached, and wait until it is actually answering before saying
68
99
  * it started. Reporting success and leaving the user to discover a crash in a
@@ -119,11 +150,14 @@ async function waitForAnnounce(log, timeoutMs) {
119
150
  try {
120
151
  const parsed = JSON.parse(line);
121
152
  if (parsed["nixamp"] === "listening") {
153
+ const urls = parsed["urls"];
122
154
  return {
123
155
  host: String(parsed["host"]),
124
156
  port: Number(parsed["port"]),
125
157
  key: parsed["key"] ?? null,
126
158
  source: String(parsed["source"]),
159
+ ...(Array.isArray(urls) ? { urls: urls } : {}),
160
+ ...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
127
161
  };
128
162
  }
129
163
  }
package/dist/main.js CHANGED
@@ -67,6 +67,9 @@ Options for serve:
67
67
  --publish list it at nixamp.com/directory without asking first
68
68
  --no-publish never list it, and do not ask
69
69
  --name NAME what to call it in the directory (default: this hostname)
70
+ --public-url URL the address this server is reachable at from outside,
71
+ when that is a tunnel or a forwarded port rather than one of
72
+ its own interfaces. Also NIXAMP_PUBLIC_URL
70
73
  --ingest accept a live stream in at POST /api/ingest
71
74
  --rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
72
75
  --rtmp-streams N how many may publish at once (default 3, a port each)
@@ -192,13 +195,8 @@ async function runDaemon(argv) {
192
195
  if (action === "start") {
193
196
  try {
194
197
  const state = await d.start(rest, entry);
195
- console.log(`nixamp daemon running (pid ${state.pid})`);
196
- const url = d.daemonUrl(state);
197
- console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
198
- console.log(` ${state.source}`);
199
- console.log("");
200
- console.log(" nixamp admin who is connected");
201
- console.log(" nixamp daemon stop when you are done");
198
+ for (const line of d.daemonLines(state))
199
+ console.log(line);
202
200
  return 0;
203
201
  }
204
202
  catch (error) {
@@ -300,7 +298,10 @@ export async function main() {
300
298
  const asked = first ?? ".";
301
299
  const target = isRemote(asked) ? asked : resolve(asked);
302
300
  const tools = detectTools();
303
- const tracks = await loadSource(tools, target);
301
+ // Names now, tags later: an ffprobe per file over a large library is minutes
302
+ // of a blank terminal before the player appears. The list is the same list;
303
+ // only the titles arrive late, and they arrive into a player already running.
304
+ const tracks = await loadSource(tools, target, false);
304
305
  if (tracks.length === 0) {
305
306
  console.error(`nixamp: no audio files under ${target}`);
306
307
  process.exit(1);
@@ -311,6 +312,22 @@ export async function main() {
311
312
  // gone. A field rather than a local, because a local assigned only inside a
312
313
  // closure stays narrowed to null for the checker.
313
314
  const handoff = { to: null };
315
+ // The titles, arriving into a player that is already up. Not awaited, and
316
+ // applied only if the list is still the one it describes.
317
+ if (!isRemote(target)) {
318
+ void loadSource(tools, target, true)
319
+ .then((tagged) => {
320
+ if (tagged.length !== state.tracks.length)
321
+ return;
322
+ if (tagged.some((track, at) => track.path !== state.tracks[at]?.path))
323
+ return;
324
+ state.tracks = tagged;
325
+ app.invalidate();
326
+ })
327
+ .catch(() => {
328
+ // Filenames play. Nothing to say about tags that would not read.
329
+ });
330
+ }
314
331
  const analyser = new Analyser(FFT_SIZE, RATE);
315
332
  const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
316
333
  // Samples accumulate until there are enough for one transform.
package/dist/server.d.ts CHANGED
@@ -47,6 +47,15 @@ export interface ServeOptions {
47
47
  publish: "ask" | "yes" | "no";
48
48
  /** What to call it in the list. Defaults to this machine's hostname. */
49
49
  name: string;
50
+ /**
51
+ * The address this server is reachable at from outside, when that is not one
52
+ * of its own interfaces: a tunnel, a reverse proxy, a forwarded port.
53
+ *
54
+ * Without it a machine behind NAT has nothing to publish -- every address it
55
+ * can see is a 192.168 one that is no use to anybody else -- so the directory
56
+ * listing is skipped and the printed links only work inside the house.
57
+ */
58
+ publicUrl: string;
50
59
  /**
51
60
  * Charge for listening once the stream is busy. Off unless asked for, and
52
61
  * useless without somewhere to pay: see NIXAMP_PAY_TO.
@@ -107,6 +116,16 @@ export interface Engine {
107
116
  * dropping every listener.
108
117
  */
109
118
  replace(tracks: Track[], root: string): void;
119
+ /**
120
+ * The same tracks, now with their tags.
121
+ *
122
+ * Startup lists filenames and begins serving immediately, because an ffprobe
123
+ * per file over a real library takes minutes; the tags arrive afterwards and
124
+ * land here. Unlike `replace` this must not disturb anything -- whoever is
125
+ * listening keeps listening, and the only visible change is that the titles
126
+ * fill in.
127
+ */
128
+ retag(tracks: Track[], root: string): void;
110
129
  stop(): void;
111
130
  }
112
131
  export declare function toRemoteTracks(tracks: Track[]): RemoteTrack[];
@@ -146,6 +165,7 @@ export declare class PlayerEngine implements Engine {
146
165
  private push;
147
166
  stop(): void;
148
167
  replace(tracks: Track[], root: string): void;
168
+ retag(tracks: Track[], root: string): void;
149
169
  }
150
170
  /** An engine with no library behind it, for the hosted PWA. */
151
171
  export declare class EmptyEngine implements Engine {
@@ -156,6 +176,7 @@ export declare class EmptyEngine implements Engine {
156
176
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
157
177
  trackPath(): undefined;
158
178
  replace(): void;
179
+ retag(): void;
159
180
  stop(): void;
160
181
  }
161
182
  /**
package/dist/server.js CHANGED
@@ -66,6 +66,7 @@ export function parseServeArgs(argv) {
66
66
  directory: false,
67
67
  publish: "ask",
68
68
  name: "",
69
+ publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
69
70
  x402: false,
70
71
  owner: "",
71
72
  ingest: false,
@@ -117,6 +118,15 @@ export function parseServeArgs(argv) {
117
118
  else if (arg === "--no-publish") {
118
119
  options.publish = "no";
119
120
  }
121
+ else if (arg === "--public-url") {
122
+ const given = value().trim();
123
+ // A hostname on its own is the likely typo, and it fails much later --
124
+ // as a directory listing nobody can open -- so it is refused here.
125
+ if (!/^https?:\/\/[^\s/]+/i.test(given)) {
126
+ throw new Error("nixamp serve: --public-url must be a URL, e.g. https://nixamp.example.com");
127
+ }
128
+ options.publicUrl = given.replace(/\/+$/, "");
129
+ }
120
130
  else if (arg === "--name") {
121
131
  options.name = value();
122
132
  }
@@ -444,6 +454,19 @@ export class PlayerEngine {
444
454
  this.state.note = "";
445
455
  this.push();
446
456
  }
457
+ retag(tracks, root) {
458
+ // Dropped rather than applied if the library moved underneath: somebody
459
+ // re-streamed while the tagging was still running, and these tags describe
460
+ // something nobody is playing any more.
461
+ if (root !== this.root || tracks.length !== this.tracks.length)
462
+ return;
463
+ if (tracks.some((track, at) => track.path !== this.tracks[at]?.path))
464
+ return;
465
+ this.tracks = tracks;
466
+ // No stop, no index reset: the only thing that changes is what the titles
467
+ // say, and every remote finds out because a snapshot goes out.
468
+ this.push();
469
+ }
447
470
  }
448
471
  /** An engine with no library behind it, for the hosted PWA. */
449
472
  export class EmptyEngine {
@@ -463,6 +486,7 @@ export class EmptyEngine {
463
486
  return undefined;
464
487
  }
465
488
  replace() { }
489
+ retag() { }
466
490
  stop() { }
467
491
  }
468
492
  const CORS = {
@@ -1804,7 +1828,18 @@ export async function serve(argv, version = "0.1.0") {
1804
1828
  const options = parseServeArgs(argv);
1805
1829
  const root = isRemote(options.root) ? options.root : resolve(options.root);
1806
1830
  const tools = detectTools();
1807
- const tracks = await loadSource(tools, root);
1831
+ // Names now, tags later.
1832
+ //
1833
+ // Reading tags is an ffprobe per file, which over a real library is minutes,
1834
+ // and every one of them used to happen before this process printed a word or
1835
+ // listened on a port. `nixamp serve ~/music` looked hung, and `nixamp daemon
1836
+ // start` was worse: it waits fifteen seconds for the announce line, killed a
1837
+ // daemon that was working perfectly, and reported a failure whose log was
1838
+ // empty because nothing had been written to it yet.
1839
+ //
1840
+ // So the filenames are enough to start: the server is up and answering in the
1841
+ // time it takes to walk the directory, and the titles fill in behind it.
1842
+ const tracks = await loadSource(tools, root, false);
1808
1843
  const engine = tracks.length > 0
1809
1844
  ? new PlayerEngine(tracks, root, tools)
1810
1845
  : new EmptyEngine(`No audio files under ${root}.`);
@@ -2050,14 +2085,45 @@ export async function serve(argv, version = "0.1.0") {
2050
2085
  return { status: done.status, stdout: done.stdout ?? "" };
2051
2086
  },
2052
2087
  };
2088
+ // The link, not the address. Without the key the address is a 401, so
2089
+ // printing a bare host:port would be printing something that does not work.
2090
+ const addresses = reachableAddresses(options.host, port, options.publicUrl);
2091
+ // Listening on every interface proves the socket is open here and nothing
2092
+ // about the path between here and the phone.
2093
+ const listening = options.host === "0.0.0.0" || options.host === "::";
2094
+ const firewall = listening ? firewallInUse(io) : null;
2053
2095
  if (options.announce) {
2054
- console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
2096
+ // Everything `nixamp daemon start` needs to print what this would have
2097
+ // printed. Without the addresses it could only reconstruct host and port,
2098
+ // which for a server bound to every interface means it printed 127.0.0.1 --
2099
+ // an address that works on exactly the machine you are already sitting at.
2100
+ // The firewall matters for the same reason: the warning was going into a
2101
+ // log file nobody reads instead of to the person who just typed the
2102
+ // command.
2103
+ console.log(JSON.stringify({
2104
+ nixamp: "listening",
2105
+ host: options.host,
2106
+ port,
2107
+ key,
2108
+ source: root,
2109
+ urls: addresses,
2110
+ firewall,
2111
+ }));
2112
+ }
2113
+ // The other half of "names now, tags later". It runs while the banner is
2114
+ // printed and while the publish prompt waits, and it is deliberately not
2115
+ // awaited: nothing downstream needs it, and a library that takes a minute to
2116
+ // read should cost nobody a minute of silence.
2117
+ if (tracks.length > 0 && !isRemote(root)) {
2118
+ void loadSource(tools, root, true)
2119
+ .then((tagged) => engine.retag(tagged, root))
2120
+ .catch(() => {
2121
+ // Filenames are a working player. A failure here is worth nothing but
2122
+ // titles that stay as they are.
2123
+ });
2055
2124
  }
2056
2125
  console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
2057
2126
  console.log("");
2058
- // The link, not the address. Without the key the address is a 401, so
2059
- // printing a bare host:port would be printing something that does not work.
2060
- const addresses = reachableAddresses(options.host, port);
2061
2127
  const width = Math.max(...addresses.map((a) => a.label.length));
2062
2128
  for (const { label, url } of addresses) {
2063
2129
  console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
@@ -2114,10 +2180,6 @@ export async function serve(argv, version = "0.1.0") {
2114
2180
  }
2115
2181
  if (web === null)
2116
2182
  console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
2117
- // Listening on every interface proves the socket is open here and nothing
2118
- // about the path between here and the phone.
2119
- const listening = options.host === "0.0.0.0" || options.host === "::";
2120
- const firewall = listening ? firewallInUse(io) : null;
2121
2183
  let closePort = null;
2122
2184
  if (firewall !== null) {
2123
2185
  const { open, close } = portCommands(firewall, port);
package/dist/share.d.ts CHANGED
@@ -31,7 +31,7 @@ export declare function classify(address: string): "private" | "cgnat" | "public
31
31
  * somewhere else can open. It is labelled for what it is, because the key in
32
32
  * the link is then the only thing between a stranger and the library.
33
33
  */
34
- export declare function reachableAddresses(host: string, port: number): {
34
+ export declare function reachableAddresses(host: string, port: number, publicUrl?: string): {
35
35
  label: string;
36
36
  url: string;
37
37
  }[];
package/dist/share.js CHANGED
@@ -80,14 +80,19 @@ export function classify(address) {
80
80
  * somewhere else can open. It is labelled for what it is, because the key in
81
81
  * the link is then the only thing between a stranger and the library.
82
82
  */
83
- export function reachableAddresses(host, port) {
83
+ export function reachableAddresses(host, port, publicUrl = "") {
84
84
  const link = (address) => {
85
85
  // A bare IPv6 address needs brackets before it is a URL.
86
86
  const authority = address.includes(":") ? `[${address}]` : address;
87
87
  return `http://${authority}:${port}`;
88
88
  };
89
+ // An address somebody told us about, because it is one this machine cannot
90
+ // know: a tunnel, a reverse proxy, or a router forwarding a port. It goes
91
+ // first so that it, and not a guess from an interface, is the address this
92
+ // stream is published under.
93
+ const told = publicUrl ? [{ label: "on the internet", url: publicUrl.replace(/\/+$/, "") }] : [];
89
94
  if (host !== "0.0.0.0" && host !== "::")
90
- return [{ label: "here", url: link(host) }];
95
+ return [...told, { label: "here", url: link(host) }];
91
96
  const LABELS = { private: "on your network", cgnat: "on tailscale", public: "on the internet" };
92
97
  const found = [];
93
98
  for (const [name, entries] of Object.entries(networkInterfaces())) {
@@ -104,6 +109,7 @@ export function reachableAddresses(host, port) {
104
109
  const order = { private: 0, cgnat: 1, public: 2 };
105
110
  found.sort((x, y) => order[x.kind] - order[y.kind]);
106
111
  return [
112
+ ...told,
107
113
  { label: "here", url: `http://localhost:${port}` },
108
114
  ...found.map(({ label, url }) => ({ label, url })),
109
115
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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/daemon.ts CHANGED
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
+ import { type Firewall, portCommands } from "./share.ts";
13
14
 
14
15
  export interface DaemonState {
15
16
  pid: number;
@@ -21,6 +22,17 @@ export interface DaemonState {
21
22
  startedAt: number;
22
23
  /** Where its output went, for when it died and you want to know why. */
23
24
  log: string;
25
+ /**
26
+ * The labelled addresses the server itself worked out, share key not applied.
27
+ *
28
+ * Recorded rather than recomputed because only the server knows them: a
29
+ * daemon bound to every interface has no single host to print, and the
30
+ * public one may be a tunnel it was told about rather than an interface
31
+ * anybody here can see. Absent on a state file written by an older nixamp.
32
+ */
33
+ urls?: { label: string; url: string }[];
34
+ /** The firewall standing between this port and the rest of the network. */
35
+ firewall?: string | null;
24
36
  }
25
37
 
26
38
  /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
@@ -82,6 +94,53 @@ export function daemonUrl(state: DaemonState): string {
82
94
  return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
83
95
  }
84
96
 
97
+ /**
98
+ * What `nixamp daemon start` prints, as lines, so it can be tested without
99
+ * starting a daemon.
100
+ *
101
+ * Every address the server found, not one loopback link: the point of a daemon
102
+ * is the phone in the other room, and 127.0.0.1 is the single address that
103
+ * cannot be handed to anybody. The firewall warning comes with it because the
104
+ * server writes that into a log file nobody reads, not to the person who just
105
+ * typed the command.
106
+ */
107
+ export function daemonLines(state: DaemonState): string[] {
108
+ const link = (url: string): string => (state.key ? `${url}/s/${state.key}` : url);
109
+ // A state file written by an older nixamp has no list, so host and port
110
+ // still stand in rather than printing nothing at all.
111
+ const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
112
+ const width = Math.max(...addresses.map((a) => a.label.length), "source".length);
113
+
114
+ const lines = [`nixamp daemon running (pid ${state.pid})`];
115
+ for (const { label, url } of addresses) lines.push(` ${label.padEnd(width)} ${link(url)}`);
116
+ lines.push(` ${"source".padEnd(width)} ${state.source}`);
117
+
118
+ if (state.firewall) {
119
+ const { open } = portCommands(state.firewall as Firewall, state.port);
120
+ lines.push(
121
+ "",
122
+ ` ${state.firewall} is running, so nothing else can reach port ${state.port} yet:`,
123
+ ` sudo ${open.join(" ")}`,
124
+ " or `nixamp daemon stop` and start again with --open-port.",
125
+ );
126
+ }
127
+ if (!addresses.some((a) => a.label === "on the internet")) {
128
+ lines.push(
129
+ "",
130
+ " None of those work from outside this network. If it should:",
131
+ " nixamp daemon start ... --public-url https://your-tunnel.example.com",
132
+ );
133
+ }
134
+
135
+ lines.push(
136
+ "",
137
+ " nixamp attach the player, in front of it",
138
+ " nixamp admin who is connected",
139
+ " nixamp daemon stop when you are done",
140
+ );
141
+ return lines;
142
+ }
143
+
85
144
  /**
86
145
  * Start one, detached, and wait until it is actually answering before saying
87
146
  * it started. Reporting success and leaving the user to discover a crash in a
@@ -142,11 +201,14 @@ async function waitForAnnounce(
142
201
  try {
143
202
  const parsed = JSON.parse(line) as { nixamp?: string } & Record<string, unknown>;
144
203
  if (parsed["nixamp"] === "listening") {
204
+ const urls = parsed["urls"];
145
205
  return {
146
206
  host: String(parsed["host"]),
147
207
  port: Number(parsed["port"]),
148
208
  key: (parsed["key"] as string | null) ?? null,
149
209
  source: String(parsed["source"]),
210
+ ...(Array.isArray(urls) ? { urls: urls as { label: string; url: string }[] } : {}),
211
+ ...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
150
212
  };
151
213
  }
152
214
  } catch {
package/src/main.ts CHANGED
@@ -91,6 +91,9 @@ Options for serve:
91
91
  --publish list it at nixamp.com/directory without asking first
92
92
  --no-publish never list it, and do not ask
93
93
  --name NAME what to call it in the directory (default: this hostname)
94
+ --public-url URL the address this server is reachable at from outside,
95
+ when that is a tunnel or a forwarded port rather than one of
96
+ its own interfaces. Also NIXAMP_PUBLIC_URL
94
97
  --ingest accept a live stream in at POST /api/ingest
95
98
  --rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
96
99
  --rtmp-streams N how many may publish at once (default 3, a port each)
@@ -221,13 +224,7 @@ async function runDaemon(argv: string[]): Promise<number> {
221
224
  if (action === "start") {
222
225
  try {
223
226
  const state = await d.start(rest, entry);
224
- console.log(`nixamp daemon running (pid ${state.pid})`);
225
- const url = d.daemonUrl(state);
226
- console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
227
- console.log(` ${state.source}`);
228
- console.log("");
229
- console.log(" nixamp admin who is connected");
230
- console.log(" nixamp daemon stop when you are done");
227
+ for (const line of d.daemonLines(state)) console.log(line);
231
228
  return 0;
232
229
  } catch (error) {
233
230
  console.error((error as Error).message);
@@ -334,7 +331,10 @@ export async function main(): Promise<void> {
334
331
  const asked = first ?? ".";
335
332
  const target = isRemote(asked) ? asked : resolve(asked);
336
333
  const tools = detectTools();
337
- const tracks = await loadSource(tools, target);
334
+ // Names now, tags later: an ffprobe per file over a large library is minutes
335
+ // of a blank terminal before the player appears. The list is the same list;
336
+ // only the titles arrive late, and they arrive into a player already running.
337
+ const tracks = await loadSource(tools, target, false);
338
338
  if (tracks.length === 0) {
339
339
  console.error(`nixamp: no audio files under ${target}`);
340
340
  process.exit(1);
@@ -347,6 +347,21 @@ export async function main(): Promise<void> {
347
347
  // closure stays narrowed to null for the checker.
348
348
  const handoff: { to: { daemon: DaemonState; url: string } | null } = { to: null };
349
349
 
350
+ // The titles, arriving into a player that is already up. Not awaited, and
351
+ // applied only if the list is still the one it describes.
352
+ if (!isRemote(target)) {
353
+ void loadSource(tools, target, true)
354
+ .then((tagged) => {
355
+ if (tagged.length !== state.tracks.length) return;
356
+ if (tagged.some((track, at) => track.path !== state.tracks[at]?.path)) return;
357
+ state.tracks = tagged;
358
+ app.invalidate();
359
+ })
360
+ .catch(() => {
361
+ // Filenames play. Nothing to say about tags that would not read.
362
+ });
363
+ }
364
+
350
365
  const analyser = new Analyser(FFT_SIZE, RATE);
351
366
  const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
352
367
  // Samples accumulate until there are enough for one transform.
package/src/server.ts CHANGED
@@ -119,6 +119,15 @@ export interface ServeOptions {
119
119
  publish: "ask" | "yes" | "no";
120
120
  /** What to call it in the list. Defaults to this machine's hostname. */
121
121
  name: string;
122
+ /**
123
+ * The address this server is reachable at from outside, when that is not one
124
+ * of its own interfaces: a tunnel, a reverse proxy, a forwarded port.
125
+ *
126
+ * Without it a machine behind NAT has nothing to publish -- every address it
127
+ * can see is a 192.168 one that is no use to anybody else -- so the directory
128
+ * listing is skipped and the printed links only work inside the house.
129
+ */
130
+ publicUrl: string;
122
131
  /**
123
132
  * Charge for listening once the stream is busy. Off unless asked for, and
124
133
  * useless without somewhere to pay: see NIXAMP_PAY_TO.
@@ -168,6 +177,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
168
177
  directory: false,
169
178
  publish: "ask",
170
179
  name: "",
180
+ publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
171
181
  x402: false,
172
182
  owner: "",
173
183
  ingest: false,
@@ -208,6 +218,14 @@ export function parseServeArgs(argv: string[]): ServeOptions {
208
218
  options.publish = "yes";
209
219
  } else if (arg === "--no-publish") {
210
220
  options.publish = "no";
221
+ } else if (arg === "--public-url") {
222
+ const given = value().trim();
223
+ // A hostname on its own is the likely typo, and it fails much later --
224
+ // as a directory listing nobody can open -- so it is refused here.
225
+ if (!/^https?:\/\/[^\s/]+/i.test(given)) {
226
+ throw new Error("nixamp serve: --public-url must be a URL, e.g. https://nixamp.example.com");
227
+ }
228
+ options.publicUrl = given.replace(/\/+$/, "");
211
229
  } else if (arg === "--name") {
212
230
  options.name = value();
213
231
  } else if (arg === "--owner") {
@@ -343,6 +361,16 @@ export interface Engine {
343
361
  * dropping every listener.
344
362
  */
345
363
  replace(tracks: Track[], root: string): void;
364
+ /**
365
+ * The same tracks, now with their tags.
366
+ *
367
+ * Startup lists filenames and begins serving immediately, because an ffprobe
368
+ * per file over a real library takes minutes; the tags arrive afterwards and
369
+ * land here. Unlike `replace` this must not disturb anything -- whoever is
370
+ * listening keeps listening, and the only visible change is that the titles
371
+ * fill in.
372
+ */
373
+ retag(tracks: Track[], root: string): void;
346
374
  stop(): void;
347
375
  }
348
376
 
@@ -542,6 +570,18 @@ export class PlayerEngine implements Engine {
542
570
  this.state.note = "";
543
571
  this.push();
544
572
  }
573
+
574
+ retag(tracks: Track[], root: string): void {
575
+ // Dropped rather than applied if the library moved underneath: somebody
576
+ // re-streamed while the tagging was still running, and these tags describe
577
+ // something nobody is playing any more.
578
+ if (root !== this.root || tracks.length !== this.tracks.length) return;
579
+ if (tracks.some((track, at) => track.path !== this.tracks[at]?.path)) return;
580
+ this.tracks = tracks;
581
+ // No stop, no index reset: the only thing that changes is what the titles
582
+ // say, and every remote finds out because a snapshot goes out.
583
+ this.push();
584
+ }
545
585
  }
546
586
 
547
587
  /** An engine with no library behind it, for the hosted PWA. */
@@ -559,6 +599,7 @@ export class EmptyEngine implements Engine {
559
599
  return undefined;
560
600
  }
561
601
  replace(): void {}
602
+ retag(): void {}
562
603
  stop(): void {}
563
604
  }
564
605
 
@@ -2062,7 +2103,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2062
2103
  const options = parseServeArgs(argv);
2063
2104
  const root = isRemote(options.root) ? options.root : resolve(options.root);
2064
2105
  const tools = detectTools();
2065
- const tracks = await loadSource(tools, root);
2106
+ // Names now, tags later.
2107
+ //
2108
+ // Reading tags is an ffprobe per file, which over a real library is minutes,
2109
+ // and every one of them used to happen before this process printed a word or
2110
+ // listened on a port. `nixamp serve ~/music` looked hung, and `nixamp daemon
2111
+ // start` was worse: it waits fifteen seconds for the announce line, killed a
2112
+ // daemon that was working perfectly, and reported a failure whose log was
2113
+ // empty because nothing had been written to it yet.
2114
+ //
2115
+ // So the filenames are enough to start: the server is up and answering in the
2116
+ // time it takes to walk the directory, and the titles fill in behind it.
2117
+ const tracks = await loadSource(tools, root, false);
2066
2118
  const engine: Engine = tracks.length > 0
2067
2119
  ? new PlayerEngine(tracks, root, tools)
2068
2120
  : new EmptyEngine(`No audio files under ${root}.`);
@@ -2338,16 +2390,52 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2338
2390
  },
2339
2391
  };
2340
2392
 
2393
+ // The link, not the address. Without the key the address is a 401, so
2394
+ // printing a bare host:port would be printing something that does not work.
2395
+ const addresses = reachableAddresses(options.host, port, options.publicUrl);
2396
+
2397
+ // Listening on every interface proves the socket is open here and nothing
2398
+ // about the path between here and the phone.
2399
+ const listening = options.host === "0.0.0.0" || options.host === "::";
2400
+ const firewall = listening ? firewallInUse(io) : null;
2401
+
2341
2402
  if (options.announce) {
2342
- console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
2403
+ // Everything `nixamp daemon start` needs to print what this would have
2404
+ // printed. Without the addresses it could only reconstruct host and port,
2405
+ // which for a server bound to every interface means it printed 127.0.0.1 --
2406
+ // an address that works on exactly the machine you are already sitting at.
2407
+ // The firewall matters for the same reason: the warning was going into a
2408
+ // log file nobody reads instead of to the person who just typed the
2409
+ // command.
2410
+ console.log(
2411
+ JSON.stringify({
2412
+ nixamp: "listening",
2413
+ host: options.host,
2414
+ port,
2415
+ key,
2416
+ source: root,
2417
+ urls: addresses,
2418
+ firewall,
2419
+ }),
2420
+ );
2421
+ }
2422
+
2423
+ // The other half of "names now, tags later". It runs while the banner is
2424
+ // printed and while the publish prompt waits, and it is deliberately not
2425
+ // awaited: nothing downstream needs it, and a library that takes a minute to
2426
+ // read should cost nobody a minute of silence.
2427
+ if (tracks.length > 0 && !isRemote(root)) {
2428
+ void loadSource(tools, root, true)
2429
+ .then((tagged) => engine.retag(tagged, root))
2430
+ .catch(() => {
2431
+ // Filenames are a working player. A failure here is worth nothing but
2432
+ // titles that stay as they are.
2433
+ });
2343
2434
  }
2344
2435
 
2345
2436
  console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
2346
2437
  console.log("");
2347
2438
 
2348
- // The link, not the address. Without the key the address is a 401, so
2349
- // printing a bare host:port would be printing something that does not work.
2350
- const addresses = reachableAddresses(options.host, port);
2351
2439
  const width = Math.max(...addresses.map((a) => a.label.length));
2352
2440
  for (const { label, url } of addresses) {
2353
2441
  console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
@@ -2404,10 +2492,6 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2404
2492
  }
2405
2493
  if (web === null) console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
2406
2494
 
2407
- // Listening on every interface proves the socket is open here and nothing
2408
- // about the path between here and the phone.
2409
- const listening = options.host === "0.0.0.0" || options.host === "::";
2410
- const firewall = listening ? firewallInUse(io) : null;
2411
2495
  let closePort: (() => void) | null = null;
2412
2496
 
2413
2497
  if (firewall !== null) {
package/src/share.ts CHANGED
@@ -93,14 +93,24 @@ export function classify(address: string): "private" | "cgnat" | "public" {
93
93
  * somewhere else can open. It is labelled for what it is, because the key in
94
94
  * the link is then the only thing between a stranger and the library.
95
95
  */
96
- export function reachableAddresses(host: string, port: number): { label: string; url: string }[] {
96
+ export function reachableAddresses(
97
+ host: string,
98
+ port: number,
99
+ publicUrl = "",
100
+ ): { label: string; url: string }[] {
97
101
  const link = (address: string): string => {
98
102
  // A bare IPv6 address needs brackets before it is a URL.
99
103
  const authority = address.includes(":") ? `[${address}]` : address;
100
104
  return `http://${authority}:${port}`;
101
105
  };
102
106
 
103
- if (host !== "0.0.0.0" && host !== "::") return [{ label: "here", url: link(host) }];
107
+ // An address somebody told us about, because it is one this machine cannot
108
+ // know: a tunnel, a reverse proxy, or a router forwarding a port. It goes
109
+ // first so that it, and not a guess from an interface, is the address this
110
+ // stream is published under.
111
+ const told = publicUrl ? [{ label: "on the internet", url: publicUrl.replace(/\/+$/, "") }] : [];
112
+
113
+ if (host !== "0.0.0.0" && host !== "::") return [...told, { label: "here", url: link(host) }];
104
114
 
105
115
  const LABELS = { private: "on your network", cgnat: "on tailscale", public: "on the internet" } as const;
106
116
  const found: { label: string; url: string; kind: keyof typeof LABELS }[] = [];
@@ -116,6 +126,7 @@ export function reachableAddresses(host: string, port: number): { label: string;
116
126
  const order = { private: 0, cgnat: 1, public: 2 } as const;
117
127
  found.sort((x, y) => order[x.kind] - order[y.kind]);
118
128
  return [
129
+ ...told,
119
130
  { label: "here", url: `http://localhost:${port}` },
120
131
  ...found.map(({ label, url }) => ({ label, url })),
121
132
  ];
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1788934596148";
2
+ const CACHE = "nixamp-1788937268228";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",