nixamp 0.5.0 → 0.5.2

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.
@@ -46,6 +46,23 @@ export interface AuthLike {
46
46
  export declare function readResult(value: unknown): AuthResult;
47
47
  /** `validateToken` answers claims directly, unlike login and register. */
48
48
  export declare function readClaims(value: unknown): Account | null;
49
+ /**
50
+ * How short a password may be, and what it must contain.
51
+ *
52
+ * Eight characters, a number somewhere in it, and no requirement about case.
53
+ * That is deliberately weaker than it was: the composition rules were the kind
54
+ * that make people write a password down, and a password is no longer the only
55
+ * way in -- a provider or a token is a better one, and is what the CLI offers
56
+ * first. What this floor is really for is keeping a one-character password out
57
+ * of the database, not pretending eight digits are strong.
58
+ */
59
+ export declare const PASSWORD_RULES: {
60
+ readonly minLength: 8;
61
+ readonly requireUppercase: false;
62
+ readonly requireLowercase: false;
63
+ readonly requireNumbers: true;
64
+ readonly requireSpecialChars: false;
65
+ };
49
66
  /** An address that could exist, and a password long enough to be worth having. */
50
67
  export declare function checkCredentials(email: unknown, password: unknown): string;
51
68
  export declare class Accounts {
package/dist/accounts.js CHANGED
@@ -47,16 +47,35 @@ export function readClaims(value) {
47
47
  const email = typeof claims["email"] === "string" ? claims["email"] : "";
48
48
  return id ? { id, email } : null;
49
49
  }
50
+ /**
51
+ * How short a password may be, and what it must contain.
52
+ *
53
+ * Eight characters, a number somewhere in it, and no requirement about case.
54
+ * That is deliberately weaker than it was: the composition rules were the kind
55
+ * that make people write a password down, and a password is no longer the only
56
+ * way in -- a provider or a token is a better one, and is what the CLI offers
57
+ * first. What this floor is really for is keeping a one-character password out
58
+ * of the database, not pretending eight digits are strong.
59
+ */
60
+ export const PASSWORD_RULES = {
61
+ minLength: 8,
62
+ requireUppercase: false,
63
+ requireLowercase: false,
64
+ requireNumbers: true,
65
+ requireSpecialChars: false,
66
+ };
50
67
  /** An address that could exist, and a password long enough to be worth having. */
51
68
  export function checkCredentials(email, password) {
52
69
  if (typeof email !== "string" || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
53
70
  return "that does not look like an email address";
54
71
  }
55
- if (typeof password !== "string" || password.length < 10) {
72
+ if (typeof password !== "string" || password.length < PASSWORD_RULES.minLength) {
56
73
  // Length is checked here so a hopeless password never reaches the
57
74
  // database. The auth module then applies its own composition rules on top,
58
- // and its refusals are passed through rather than swallowed.
59
- return "a password needs at least 10 characters";
75
+ // and its refusals are passed through rather than swallowed -- which is why
76
+ // the two have to agree about the minimum, or a password this accepts is
77
+ // refused a layer down with a different sentence.
78
+ return `a password needs at least ${PASSWORD_RULES.minLength} characters`;
60
79
  }
61
80
  if (password.length > 200)
62
81
  return "that password is too long";
@@ -80,6 +99,10 @@ export class Accounts {
80
99
  createAuthSystem({
81
100
  adapter,
82
101
  jwtSecret: options.secret,
102
+ // The module defaults to requiring an uppercase and a lowercase
103
+ // letter. Its rules have to match the ones checked above, or a password
104
+ // this accepts is refused a layer down in a different sentence.
105
+ passwordOptions: { ...PASSWORD_RULES },
83
106
  });
84
107
  this.tokens = adapter ? new Tokens(adapter) : null;
85
108
  this.identities = adapter ? new Identities(adapter, adapter) : null;
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)
@@ -300,7 +303,10 @@ export async function main() {
300
303
  const asked = first ?? ".";
301
304
  const target = isRemote(asked) ? asked : resolve(asked);
302
305
  const tools = detectTools();
303
- const tracks = await loadSource(tools, target);
306
+ // Names now, tags later: an ffprobe per file over a large library is minutes
307
+ // of a blank terminal before the player appears. The list is the same list;
308
+ // only the titles arrive late, and they arrive into a player already running.
309
+ const tracks = await loadSource(tools, target, false);
304
310
  if (tracks.length === 0) {
305
311
  console.error(`nixamp: no audio files under ${target}`);
306
312
  process.exit(1);
@@ -311,6 +317,22 @@ export async function main() {
311
317
  // gone. A field rather than a local, because a local assigned only inside a
312
318
  // closure stays narrowed to null for the checker.
313
319
  const handoff = { to: null };
320
+ // The titles, arriving into a player that is already up. Not awaited, and
321
+ // applied only if the list is still the one it describes.
322
+ if (!isRemote(target)) {
323
+ void loadSource(tools, target, true)
324
+ .then((tagged) => {
325
+ if (tagged.length !== state.tracks.length)
326
+ return;
327
+ if (tagged.some((track, at) => track.path !== state.tracks[at]?.path))
328
+ return;
329
+ state.tracks = tagged;
330
+ app.invalidate();
331
+ })
332
+ .catch(() => {
333
+ // Filenames play. Nothing to say about tags that would not read.
334
+ });
335
+ }
314
336
  const analyser = new Analyser(FFT_SIZE, RATE);
315
337
  const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
316
338
  // 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}.`);
@@ -2053,11 +2088,23 @@ export async function serve(argv, version = "0.1.0") {
2053
2088
  if (options.announce) {
2054
2089
  console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
2055
2090
  }
2091
+ // The other half of "names now, tags later". It runs while the banner is
2092
+ // printed and while the publish prompt waits, and it is deliberately not
2093
+ // awaited: nothing downstream needs it, and a library that takes a minute to
2094
+ // read should cost nobody a minute of silence.
2095
+ if (tracks.length > 0 && !isRemote(root)) {
2096
+ void loadSource(tools, root, true)
2097
+ .then((tagged) => engine.retag(tagged, root))
2098
+ .catch(() => {
2099
+ // Filenames are a working player. A failure here is worth nothing but
2100
+ // titles that stay as they are.
2101
+ });
2102
+ }
2056
2103
  console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
2057
2104
  console.log("");
2058
2105
  // The link, not the address. Without the key the address is a 401, so
2059
2106
  // printing a bare host:port would be printing something that does not work.
2060
- const addresses = reachableAddresses(options.host, port);
2107
+ const addresses = reachableAddresses(options.host, port, options.publicUrl);
2061
2108
  const width = Math.max(...addresses.map((a) => a.label.length));
2062
2109
  for (const { label, url } of addresses) {
2063
2110
  console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
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.0",
3
+ "version": "0.5.2",
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/accounts.ts CHANGED
@@ -90,16 +90,36 @@ export function readClaims(value: unknown): Account | null {
90
90
  return id ? { id, email } : null;
91
91
  }
92
92
 
93
+ /**
94
+ * How short a password may be, and what it must contain.
95
+ *
96
+ * Eight characters, a number somewhere in it, and no requirement about case.
97
+ * That is deliberately weaker than it was: the composition rules were the kind
98
+ * that make people write a password down, and a password is no longer the only
99
+ * way in -- a provider or a token is a better one, and is what the CLI offers
100
+ * first. What this floor is really for is keeping a one-character password out
101
+ * of the database, not pretending eight digits are strong.
102
+ */
103
+ export const PASSWORD_RULES = {
104
+ minLength: 8,
105
+ requireUppercase: false,
106
+ requireLowercase: false,
107
+ requireNumbers: true,
108
+ requireSpecialChars: false,
109
+ } as const;
110
+
93
111
  /** An address that could exist, and a password long enough to be worth having. */
94
112
  export function checkCredentials(email: unknown, password: unknown): string {
95
113
  if (typeof email !== "string" || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
96
114
  return "that does not look like an email address";
97
115
  }
98
- if (typeof password !== "string" || password.length < 10) {
116
+ if (typeof password !== "string" || password.length < PASSWORD_RULES.minLength) {
99
117
  // Length is checked here so a hopeless password never reaches the
100
118
  // database. The auth module then applies its own composition rules on top,
101
- // and its refusals are passed through rather than swallowed.
102
- return "a password needs at least 10 characters";
119
+ // and its refusals are passed through rather than swallowed -- which is why
120
+ // the two have to agree about the minimum, or a password this accepts is
121
+ // refused a layer down with a different sentence.
122
+ return `a password needs at least ${PASSWORD_RULES.minLength} characters`;
103
123
  }
104
124
  if (password.length > 200) return "that password is too long";
105
125
  return "";
@@ -125,6 +145,10 @@ export class Accounts {
125
145
  (createAuthSystem({
126
146
  adapter,
127
147
  jwtSecret: options.secret,
148
+ // The module defaults to requiring an uppercase and a lowercase
149
+ // letter. Its rules have to match the ones checked above, or a password
150
+ // this accepts is refused a layer down in a different sentence.
151
+ passwordOptions: { ...PASSWORD_RULES },
128
152
  }) as AuthLike);
129
153
  this.tokens = adapter ? new Tokens(adapter) : null;
130
154
  this.identities = adapter ? new Identities(adapter, adapter) : null;
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)
@@ -334,7 +337,10 @@ export async function main(): Promise<void> {
334
337
  const asked = first ?? ".";
335
338
  const target = isRemote(asked) ? asked : resolve(asked);
336
339
  const tools = detectTools();
337
- const tracks = await loadSource(tools, target);
340
+ // Names now, tags later: an ffprobe per file over a large library is minutes
341
+ // of a blank terminal before the player appears. The list is the same list;
342
+ // only the titles arrive late, and they arrive into a player already running.
343
+ const tracks = await loadSource(tools, target, false);
338
344
  if (tracks.length === 0) {
339
345
  console.error(`nixamp: no audio files under ${target}`);
340
346
  process.exit(1);
@@ -347,6 +353,21 @@ export async function main(): Promise<void> {
347
353
  // closure stays narrowed to null for the checker.
348
354
  const handoff: { to: { daemon: DaemonState; url: string } | null } = { to: null };
349
355
 
356
+ // The titles, arriving into a player that is already up. Not awaited, and
357
+ // applied only if the list is still the one it describes.
358
+ if (!isRemote(target)) {
359
+ void loadSource(tools, target, true)
360
+ .then((tagged) => {
361
+ if (tagged.length !== state.tracks.length) return;
362
+ if (tagged.some((track, at) => track.path !== state.tracks[at]?.path)) return;
363
+ state.tracks = tagged;
364
+ app.invalidate();
365
+ })
366
+ .catch(() => {
367
+ // Filenames play. Nothing to say about tags that would not read.
368
+ });
369
+ }
370
+
350
371
  const analyser = new Analyser(FFT_SIZE, RATE);
351
372
  const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
352
373
  // 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}.`);
@@ -2342,12 +2394,25 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2342
2394
  console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
2343
2395
  }
2344
2396
 
2397
+ // The other half of "names now, tags later". It runs while the banner is
2398
+ // printed and while the publish prompt waits, and it is deliberately not
2399
+ // awaited: nothing downstream needs it, and a library that takes a minute to
2400
+ // read should cost nobody a minute of silence.
2401
+ if (tracks.length > 0 && !isRemote(root)) {
2402
+ void loadSource(tools, root, true)
2403
+ .then((tagged) => engine.retag(tagged, root))
2404
+ .catch(() => {
2405
+ // Filenames are a working player. A failure here is worth nothing but
2406
+ // titles that stay as they are.
2407
+ });
2408
+ }
2409
+
2345
2410
  console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
2346
2411
  console.log("");
2347
2412
 
2348
2413
  // The link, not the address. Without the key the address is a 401, so
2349
2414
  // printing a bare host:port would be printing something that does not work.
2350
- const addresses = reachableAddresses(options.host, port);
2415
+ const addresses = reachableAddresses(options.host, port, options.publicUrl);
2351
2416
  const width = Math.max(...addresses.map((a) => a.label.length));
2352
2417
  for (const { label, url } of addresses) {
2353
2418
  console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
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
  ];
@@ -73,5 +73,16 @@ declare module "@profullstack/auth-system" {
73
73
  jwtSecret?: string;
74
74
  accessTokenExpiry?: string | number;
75
75
  refreshTokenExpiry?: string | number;
76
+ /**
77
+ * Composition rules. Every "require" defaults to true except
78
+ * requireSpecialChars, so leaving this out is stricter than passing it.
79
+ */
80
+ passwordOptions?: {
81
+ minLength?: number;
82
+ requireUppercase?: boolean;
83
+ requireLowercase?: boolean;
84
+ requireNumbers?: boolean;
85
+ requireSpecialChars?: boolean;
86
+ };
76
87
  }): AuthSystem;
77
88
  }
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1788934163965";
2
+ const CACHE = "nixamp-1788936569431";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",