nixamp 0.7.6 → 0.7.8

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/dist/admin.js CHANGED
@@ -21,7 +21,15 @@ export function resolveTarget(argv) {
21
21
  }
22
22
  const state = readState();
23
23
  if (state === null) {
24
- throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
24
+ // Named, with an example. "or pass --url" is only an instruction if you
25
+ // already know what belongs after it, and the whole point of this message
26
+ // is that you are looking at a machine you have no handle on.
27
+ throw new Error("nixamp: no daemon is running on this machine.\n" +
28
+ " Start one: nixamp daemon start ~/Music\n" +
29
+ " Or administer another machine, with its share link:\n" +
30
+ " nixamp admin --url https://server1.you.nixamp.com:4321 --key KEY\n" +
31
+ " The URL and key are the two halves of the link that server printed:\n" +
32
+ " https://host:4321/s/KEY");
25
33
  }
26
34
  const url_ = daemonUrl(state);
27
35
  // Talking to our own daemon, whose certificate names somewhere else. Nothing
package/dist/main.js CHANGED
@@ -621,7 +621,11 @@ export function view({ ui, theme, height }, state) {
621
621
  }
622
622
  if (import.meta.main) {
623
623
  main().catch((error) => {
624
- console.error(error);
624
+ // The same rule the installed launcher uses: a message we wrote is one the
625
+ // reader can act on, and printing a stack over it buries the sentence that
626
+ // says what to do.
627
+ const message = error instanceof Error ? error.message : String(error);
628
+ console.error(message.startsWith("nixamp:") ? message : error);
625
629
  process.exit(1);
626
630
  });
627
631
  }
package/dist/owner.js CHANGED
@@ -85,7 +85,17 @@ export const ADMIN_PATHS = [
85
85
  "/api/ingest",
86
86
  "/api/admin",
87
87
  ];
88
+ /**
89
+ * Going live and coming back off, which is administering a server.
90
+ *
91
+ * Listed separately from ADMIN_PATHS on purpose: those match by prefix, and
92
+ * `/api/live` itself is the public listen address -- the one the phone line is
93
+ * handed. Gating it by prefix would shut the front door to lock the office.
94
+ */
95
+ const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
88
96
  export function needsAdmin(path, method = "GET") {
97
+ if (LIVE_CONTROL.includes(path))
98
+ return true;
89
99
  if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))
90
100
  return true;
91
101
  // Publishing to a channel, or ending one, is administering the server.
package/dist/server.d.ts CHANGED
@@ -160,6 +160,8 @@ export interface Engine {
160
160
  * this server somewhere else", which throws the library away on purpose.
161
161
  */
162
162
  replace(tracks: Track[], root: string): void;
163
+ /** The library, arriving after the server was already listening. */
164
+ fill(tracks: Track[], root: string): void;
163
165
  /**
164
166
  * Play something as well as everything here.
165
167
  *
@@ -270,6 +272,21 @@ export declare class PlayerEngine implements Engine {
270
272
  */
271
273
  drop(group: string): number;
272
274
  groups(): string[];
275
+ /**
276
+ * The library, arriving after the server was already listening.
277
+ *
278
+ * Walking a directory is the slow part of starting -- eighty thousand files
279
+ * under a home directory takes far longer than the fifteen seconds
280
+ * `nixamp daemon start` waits for the server to say it is up, so starting a
281
+ * daemon with no source given looked exactly like hanging and then failed
282
+ * about a server that was working. The port opens first now and this puts
283
+ * the library in behind it.
284
+ *
285
+ * Unlike `replace` it stops nothing and clears no listeners: whoever
286
+ * connected in the first second is still connected, and simply sees the
287
+ * playlist appear.
288
+ */
289
+ fill(tracks: Loaded[], root: string): void;
273
290
  retag(tracks: Track[], root: string): void;
274
291
  }
275
292
  /** An engine with no library behind it, for the hosted PWA. */
@@ -281,6 +298,7 @@ export declare class EmptyEngine implements Engine {
281
298
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
282
299
  trackPath(): undefined;
283
300
  replace(): void;
301
+ fill(): void;
284
302
  add(): number;
285
303
  drop(): number;
286
304
  groups(): string[];
@@ -339,6 +357,43 @@ export interface HandlerOptions {
339
357
  destinations: Destination[];
340
358
  settings: EncoderSettings;
341
359
  };
360
+ /**
361
+ * Going live: whether this server is listed, and how to change that.
362
+ *
363
+ * Listing used to be a question asked once at startup and never again, so a
364
+ * server started with --no-publish had no listing, no phone code, and no
365
+ * link to hand anybody -- and no way to change its mind short of stopping
366
+ * and starting it. It is an action now, because that is what it is.
367
+ */
368
+ live?: {
369
+ status: () => {
370
+ live: boolean;
371
+ code: string;
372
+ name: string;
373
+ url: string;
374
+ possible: boolean;
375
+ };
376
+ start: () => Promise<{
377
+ live: boolean;
378
+ code: string;
379
+ name: string;
380
+ url: string;
381
+ error?: string;
382
+ }>;
383
+ stop: () => Promise<void>;
384
+ };
385
+ /**
386
+ * Where OBS should point, one entry per stream this server will accept.
387
+ *
388
+ * There is deliberately no single link. ffmpeg's RTMP listener serves one
389
+ * connection per process, so three people going live at once is three ports
390
+ * and three URLs -- and a panel offering one address for all of them would
391
+ * be offering an address that works exactly once.
392
+ */
393
+ publishUrls?: () => {
394
+ id: string;
395
+ url: string;
396
+ }[];
342
397
  /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
343
398
  accounts?: Accounts;
344
399
  /** Providers to sign in with, and the terminals waiting to be connected. */
package/dist/server.js CHANGED
@@ -41,7 +41,7 @@ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
41
41
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
42
42
  import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
43
43
  import { codecsOf, videoArgs } from "./audio.js";
44
- import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
44
+ import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
45
45
  import { extname, join, normalize, resolve, sep } from "node:path";
46
46
  import { fileURLToPath } from "node:url";
47
47
  import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
@@ -584,6 +584,30 @@ export class PlayerEngine {
584
584
  }
585
585
  return seen;
586
586
  }
587
+ /**
588
+ * The library, arriving after the server was already listening.
589
+ *
590
+ * Walking a directory is the slow part of starting -- eighty thousand files
591
+ * under a home directory takes far longer than the fifteen seconds
592
+ * `nixamp daemon start` waits for the server to say it is up, so starting a
593
+ * daemon with no source given looked exactly like hanging and then failed
594
+ * about a server that was working. The port opens first now and this puts
595
+ * the library in behind it.
596
+ *
597
+ * Unlike `replace` it stops nothing and clears no listeners: whoever
598
+ * connected in the first second is still connected, and simply sees the
599
+ * playlist appear.
600
+ */
601
+ fill(tracks, root) {
602
+ // Something is already loaded, so this is a scan that finished after
603
+ // somebody pointed the server elsewhere. Theirs wins.
604
+ if (this.tracks.length > 0)
605
+ return;
606
+ this.tracks = tracks;
607
+ this.root = root;
608
+ this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
609
+ this.push(true);
610
+ }
587
611
  retag(tracks, root) {
588
612
  // Matched by path rather than by position, because the list is no longer
589
613
  // required to be the one that was sent for tagging: somebody can add an
@@ -630,6 +654,7 @@ export class EmptyEngine {
630
654
  return undefined;
631
655
  }
632
656
  replace() { }
657
+ fill() { }
633
658
  add() {
634
659
  return 0;
635
660
  }
@@ -1789,12 +1814,44 @@ export function createHandler(engine, options) {
1789
1814
  }
1790
1815
  // Everything the admin view draws, in one request: who is connected, and
1791
1816
  // what this server is.
1817
+ // Going live, and coming back off. Admin-gated by ADMIN_PATHS, because
1818
+ // listing somebody's machine in a public directory is not a thing a
1819
+ // listener gets to do.
1820
+ if (path === "/api/live/state") {
1821
+ if (!options.live) {
1822
+ json(response, 200, { live: false, possible: false, code: "", name: "", url: "" });
1823
+ return;
1824
+ }
1825
+ json(response, 200, options.live.status());
1826
+ return;
1827
+ }
1828
+ if (path === "/api/live/start" || path === "/api/live/stop") {
1829
+ if (request.method !== "POST") {
1830
+ json(response, 405, { error: "POST only" });
1831
+ return;
1832
+ }
1833
+ if (!options.live) {
1834
+ json(response, 409, { error: "this nixamp cannot be listed; it has no address the world can reach" });
1835
+ return;
1836
+ }
1837
+ if (path === "/api/live/stop") {
1838
+ await options.live.stop();
1839
+ json(response, 200, options.live.status());
1840
+ return;
1841
+ }
1842
+ const started = await options.live.start();
1843
+ json(response, started.error ? 502 : 200, started);
1844
+ return;
1845
+ }
1792
1846
  if (path === "/api/connections") {
1793
1847
  json(response, 200, {
1794
1848
  connections: tracker.list(),
1795
1849
  active: tracker.active,
1796
1850
  startedAt: started,
1797
1851
  now: Date.now(),
1852
+ // Where to point OBS. Printed at startup since RTMP was added, which
1853
+ // is no use at all to somebody looking at the admin panel a day later.
1854
+ publish: options.publishUrls?.() ?? [],
1798
1855
  });
1799
1856
  return;
1800
1857
  }
@@ -2341,10 +2398,9 @@ export async function serve(argv, version = "0.1.0") {
2341
2398
  //
2342
2399
  // So the filenames are enough to start: the server is up and answering in the
2343
2400
  // time it takes to walk the directory, and the titles fill in behind it.
2344
- const tracks = await loadSource(tools, root, false);
2345
- const engine = tracks.length > 0
2346
- ? new PlayerEngine(tracks, root, tools)
2347
- : new EmptyEngine(`No audio files under ${root}.`);
2401
+ // Empty on purpose. The walk happens below, once the port is open: it is
2402
+ // the slowest part of starting and nothing about it needs to happen first.
2403
+ const engine = new PlayerEngine([], root, tools);
2348
2404
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
2349
2405
  const key = options.key ? newKey() : null;
2350
2406
  // Minted whether or not it is published, so `nixamp admin` and the operator
@@ -2543,11 +2599,59 @@ export async function serve(argv, version = "0.1.0") {
2543
2599
  }
2544
2600
  })()
2545
2601
  : undefined;
2602
+ // Filled in below, when the RTMP listeners are opened. Read through a
2603
+ // function so the handler sees the list rather than the empty array it was
2604
+ // built with.
2605
+ let publishUrls = [];
2606
+ // Declared up here, not beside the publishing below: the port opens before
2607
+ // that code runs, so an admin asking to go live in the first moments would
2608
+ // otherwise reach a binding that has not been initialised.
2609
+ let publisher = null;
2610
+ let listing = null;
2611
+ let publishable_;
2546
2612
  const server = createServer(engine, {
2547
2613
  web,
2548
2614
  media: options.media,
2549
2615
  owner,
2550
2616
  channels,
2617
+ publishUrls: () => publishUrls,
2618
+ live: {
2619
+ status: () => ({
2620
+ live: publisher !== null,
2621
+ code: listing?.code ?? "",
2622
+ name: listing?.name ?? (options.name || hostname()),
2623
+ url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
2624
+ // Whether going live is even possible here. A laptop behind a router
2625
+ // with no address the world can reach cannot be listed, and a button
2626
+ // that could only fail is worse than one that is not offered.
2627
+ possible: publishable_ !== undefined,
2628
+ }),
2629
+ start: async () => {
2630
+ if (publisher === null)
2631
+ publisher = makePublisher();
2632
+ if (publisher === null) {
2633
+ return { live: false, code: "", name: "", url: "", error: "this machine has no address the world can reach" };
2634
+ }
2635
+ const first = await publisher.start();
2636
+ if (first === null) {
2637
+ // Nothing was listed, so nothing should claim to be: a publisher
2638
+ // left running here would heartbeat at a directory that refused it.
2639
+ await publisher.stop();
2640
+ publisher = null;
2641
+ return {
2642
+ live: false, code: "", name: "", url: "",
2643
+ error: `${DEFAULT_DIRECTORY} would not list this stream. Run \`nixamp login\` on that machine.`,
2644
+ };
2645
+ }
2646
+ listing = first;
2647
+ return { live: true, code: first.code, name: first.name, url: first.url };
2648
+ },
2649
+ stop: async () => {
2650
+ await publisher?.stop();
2651
+ publisher = null;
2652
+ listing = null;
2653
+ },
2654
+ },
2551
2655
  ...(ingest ? { ingest } : {}),
2552
2656
  broadcaster,
2553
2657
  broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
@@ -2651,19 +2755,30 @@ export async function serve(argv, version = "0.1.0") {
2651
2755
  guessedPublic: guessedPublic !== "",
2652
2756
  }));
2653
2757
  }
2654
- // The other half of "names now, tags later". It runs while the banner is
2655
- // printed and while the publish prompt waits, and it is deliberately not
2656
- // awaited: nothing downstream needs it, and a library that takes a minute to
2657
- // read should cost nobody a minute of silence.
2658
- if (tracks.length > 0 && !isRemote(root)) {
2659
- void loadTagged(tools, root)
2758
+ // Names now, tags later, and both after the door is open. Not awaited:
2759
+ // nothing below needs the library, and a directory that takes a minute to
2760
+ // walk should cost nobody a minute of not being able to connect.
2761
+ void loadSource(tools, root, false)
2762
+ .then((found) => {
2763
+ engine.fill(found, root);
2764
+ if (found.length === 0) {
2765
+ console.log(`nixamp serve — no audio files under ${root}`);
2766
+ return;
2767
+ }
2768
+ console.log(`nixamp serve — ${found.length} tracks under ${root}`);
2769
+ if (isRemote(root))
2770
+ return;
2771
+ return loadTagged(tools, root)
2660
2772
  .then((tagged) => engine.retag(tagged, root))
2661
2773
  .catch(() => {
2662
- // Filenames are a working player. A failure here is worth nothing but
2663
- // titles that stay as they are.
2774
+ // Filenames are a working player. A failure here is worth nothing
2775
+ // but titles that stay as they are.
2664
2776
  });
2665
- }
2666
- console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
2777
+ })
2778
+ .catch((error) => {
2779
+ console.log(`nixamp: could not read ${root}: ${error.message}`);
2780
+ });
2781
+ console.log(`nixamp serve — reading ${root}`);
2667
2782
  console.log("");
2668
2783
  const width = Math.max(...addresses.map((a) => a.label.length));
2669
2784
  for (const { label, url } of addresses) {
@@ -2716,9 +2831,13 @@ export async function serve(argv, version = "0.1.0") {
2716
2831
  }));
2717
2832
  rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
2718
2833
  rtmp.listen(slots);
2834
+ publishUrls = slots.map((slot) => ({
2835
+ id: slot.id,
2836
+ url: `rtmp://${host}:${slot.port}/live/${listenKey ?? "live"}`,
2837
+ }));
2719
2838
  console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
2720
- for (const slot of slots) {
2721
- console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
2839
+ for (const entry of publishUrls) {
2840
+ console.log(` ${entry.url} -> "${entry.id}"`);
2722
2841
  }
2723
2842
  }
2724
2843
  if (destinations.length > 0) {
@@ -2764,49 +2883,67 @@ export async function serve(argv, version = "0.1.0") {
2764
2883
  }
2765
2884
  // The listing carries the listen link, and only ever a public address: an
2766
2885
  // entry pointing at 192.168.1.5 is one nobody outside that house can open.
2767
- const publishable_ = addresses.find((a) => a.label === "on the internet")
2886
+ // Never a bare IP over https: a certificate is issued for a name, so a
2887
+ // listing pointing at one is a listing nobody can open.
2888
+ publishable_ = addresses.find((a) => a.label === "on the internet" && certifiable(a.url))
2889
+ ?? addresses.find((a) => a.label === "on tailscale" && certifiable(a.url))
2890
+ ?? addresses.find((a) => a.label === "on the internet")
2768
2891
  ?? addresses.find((a) => a.label === "on tailscale");
2769
- let publisher = null;
2770
- if (options.publish !== "no" && publishable_) {
2892
+ /**
2893
+ * Make a publisher for this server. Called at startup when the operator says
2894
+ * yes, and again whenever somebody goes live from the admin panel.
2895
+ *
2896
+ * A declaration rather than an assignment, so it exists from the moment the
2897
+ * function is entered -- the port is open well before this line is reached.
2898
+ */
2899
+ function makePublisher() {
2900
+ if (!publishable_)
2901
+ return null;
2771
2902
  const listen = shareLink(publishable_.url, listenKey);
2772
2903
  // Announced next to the listen link, not instead of it: one is for a person
2773
2904
  // with a browser, the other for the phone line and anything else that is
2774
2905
  // handed one address and expected to play it.
2775
2906
  const audio = audioLink(publishable_.url, listenKey);
2907
+ return new Publisher({
2908
+ directory: DEFAULT_DIRECTORY,
2909
+ name: options.name || hostname(),
2910
+ url: listen,
2911
+ audio,
2912
+ // Asked of the engine rather than a variable, because the library is
2913
+ // now read after the port opens and may still be arriving.
2914
+ tracks: engine.snapshot(false).trackCount,
2915
+ // From `nixamp login`. The directory will not list a stream it cannot
2916
+ // attribute to somebody, because a listing is now a phone code that
2917
+ // costs money to answer.
2918
+ ...(session?.token ? { token: session.token } : {}),
2919
+ onRefused: () => {
2920
+ console.log("");
2921
+ console.log(" nixamp.com would not list this stream: it needs an account.");
2922
+ console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
2923
+ },
2924
+ nowPlaying: () => {
2925
+ const snapshot = engine.snapshot();
2926
+ return snapshot.tracks?.[snapshot.index]?.title ?? "";
2927
+ },
2928
+ onConfig: (remote) => {
2929
+ const next = applyRemoteConfig(paywallConfig, remote?.x402);
2930
+ if (JSON.stringify(next) === JSON.stringify(paywallConfig))
2931
+ return;
2932
+ paywallConfig = next;
2933
+ console.log(next.enabled
2934
+ ? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
2935
+ : " nixamp.com turned paid listening off.");
2936
+ },
2937
+ });
2938
+ }
2939
+ if (options.publish !== "no" && publishable_) {
2940
+ const listen = shareLink(publishable_.url, listenKey);
2776
2941
  const wanted = options.publish === "yes"
2777
2942
  ? true
2778
2943
  : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
2779
2944
  if (wanted) {
2780
- publisher = new Publisher({
2781
- directory: DEFAULT_DIRECTORY,
2782
- name: options.name || hostname(),
2783
- url: listen,
2784
- audio,
2785
- tracks: tracks.length,
2786
- // From `nixamp login`. The directory will not list a stream it cannot
2787
- // attribute to somebody, because a listing is now a phone code that
2788
- // costs money to answer.
2789
- ...(session?.token ? { token: session.token } : {}),
2790
- onRefused: () => {
2791
- console.log("");
2792
- console.log(" nixamp.com would not list this stream: it needs an account.");
2793
- console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
2794
- },
2795
- nowPlaying: () => {
2796
- const snapshot = engine.snapshot();
2797
- return snapshot.tracks?.[snapshot.index]?.title ?? "";
2798
- },
2799
- onConfig: (remote) => {
2800
- const next = applyRemoteConfig(paywallConfig, remote?.x402);
2801
- if (JSON.stringify(next) === JSON.stringify(paywallConfig))
2802
- return;
2803
- paywallConfig = next;
2804
- console.log(next.enabled
2805
- ? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
2806
- : " nixamp.com turned paid listening off.");
2807
- },
2808
- });
2809
- const listing = await publisher.start();
2945
+ publisher = makePublisher();
2946
+ listing = (await publisher?.start()) ?? null;
2810
2947
  console.log("");
2811
2948
  console.log(listing
2812
2949
  ? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
package/dist/session.js CHANGED
@@ -512,7 +512,9 @@ export async function servers(argv, fetcher = fetch) {
512
512
  const daemon = await import("./daemon.js");
513
513
  const state = daemon.readState();
514
514
  if (state === null) {
515
- console.error("nixamp: no daemon is running here. Start one, or pass a URL.");
515
+ console.error("nixamp: no daemon is running here.\n" +
516
+ " Start one: nixamp daemon start ~/Music\n" +
517
+ " Or give the share link of the one you mean: https://host:4321/s/KEY");
516
518
  return 1;
517
519
  }
518
520
  // The address worth remembering is the one somebody else can open.
package/dist/share.d.ts CHANGED
@@ -45,6 +45,16 @@ export declare function lookupPublicIp(send?: typeof fetch, timeoutMs?: number):
45
45
  * somewhere else can open. It is labelled for what it is, because the key in
46
46
  * the link is then the only thing between a stranger and the library.
47
47
  */
48
+ /**
49
+ * Whether a browser could ever verify a certificate for this address.
50
+ *
51
+ * A certificate is issued for a name. Handed an https link to a bare IP, a
52
+ * browser has nothing to match it against and refuses the connection before it
53
+ * asks anything -- and from the page it looks identical to a machine that is
54
+ * switched off. Offering such a link is offering one that cannot work, so the
55
+ * links are labelled and the ones that can work go first.
56
+ */
57
+ export declare function certifiable(url: string): boolean;
48
58
  export declare function reachableAddresses(host: string, port: number, publicUrl?: string, scheme?: "http" | "https"): {
49
59
  label: string;
50
60
  url: string;
package/dist/share.js CHANGED
@@ -115,6 +115,25 @@ export async function lookupPublicIp(send = fetch, timeoutMs = 2500) {
115
115
  * somewhere else can open. It is labelled for what it is, because the key in
116
116
  * the link is then the only thing between a stranger and the library.
117
117
  */
118
+ /**
119
+ * Whether a browser could ever verify a certificate for this address.
120
+ *
121
+ * A certificate is issued for a name. Handed an https link to a bare IP, a
122
+ * browser has nothing to match it against and refuses the connection before it
123
+ * asks anything -- and from the page it looks identical to a machine that is
124
+ * switched off. Offering such a link is offering one that cannot work, so the
125
+ * links are labelled and the ones that can work go first.
126
+ */
127
+ export function certifiable(url) {
128
+ if (!url.startsWith("https://"))
129
+ return true;
130
+ try {
131
+ return !isIpAddress(new URL(url).hostname.replace(/^\[|\]$/g, ""));
132
+ }
133
+ catch {
134
+ return true;
135
+ }
136
+ }
118
137
  export function reachableAddresses(host, port, publicUrl = "", scheme = "http") {
119
138
  const link = (address) => {
120
139
  // A bare IPv6 address needs brackets before it is a URL.
@@ -143,11 +162,22 @@ export function reachableAddresses(host, port, publicUrl = "", scheme = "http")
143
162
  }
144
163
  const order = { private: 0, cgnat: 1, public: 2 };
145
164
  found.sort((x, y) => order[x.kind] - order[y.kind]);
146
- return [
165
+ const all = [
147
166
  ...told,
148
167
  { label: "here", url: `${scheme}://localhost:${port}` },
149
168
  ...found.map(({ label, url }) => ({ label, url })),
150
169
  ];
170
+ // Serving https, an address that is a bare IP cannot be verified by anyone,
171
+ // so it is said last and said differently rather than handed out as though
172
+ // it were a link somebody could use.
173
+ if (scheme !== "https")
174
+ return all;
175
+ return [
176
+ ...all.filter((entry) => certifiable(entry.url)),
177
+ ...all
178
+ .filter((entry) => !certifiable(entry.url))
179
+ .map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
180
+ ];
151
181
  }
152
182
  /** The full link, key and all. */
153
183
  export function shareLink(base, key) {
@@ -187,6 +217,11 @@ export function allowedForListening(path) {
187
217
  // album out of somebody else's playlist.
188
218
  if (path === "/api/source" || path.startsWith("/api/source/"))
189
219
  return false;
220
+ // Listing this machine in a public directory is not listening to it. The
221
+ // listen address itself, /api/live, stays open: that is the thing a listen
222
+ // key is for.
223
+ if (path === "/api/live/state" || path === "/api/live/start" || path === "/api/live/stop")
224
+ return false;
190
225
  return true;
191
226
  }
192
227
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
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/admin.ts CHANGED
@@ -57,7 +57,17 @@ export function resolveTarget(argv: string[]): AdminOptions {
57
57
 
58
58
  const state = readState();
59
59
  if (state === null) {
60
- throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
60
+ // Named, with an example. "or pass --url" is only an instruction if you
61
+ // already know what belongs after it, and the whole point of this message
62
+ // is that you are looking at a machine you have no handle on.
63
+ throw new Error(
64
+ "nixamp: no daemon is running on this machine.\n" +
65
+ " Start one: nixamp daemon start ~/Music\n" +
66
+ " Or administer another machine, with its share link:\n" +
67
+ " nixamp admin --url https://server1.you.nixamp.com:4321 --key KEY\n" +
68
+ " The URL and key are the two halves of the link that server printed:\n" +
69
+ " https://host:4321/s/KEY",
70
+ );
61
71
  }
62
72
  const url_ = daemonUrl(state);
63
73
  // Talking to our own daemon, whose certificate names somewhere else. Nothing
package/src/main.ts CHANGED
@@ -634,7 +634,11 @@ export function view(
634
634
 
635
635
  if (import.meta.main) {
636
636
  main().catch((error) => {
637
- console.error(error);
637
+ // The same rule the installed launcher uses: a message we wrote is one the
638
+ // reader can act on, and printing a stack over it buries the sentence that
639
+ // says what to do.
640
+ const message = error instanceof Error ? error.message : String(error);
641
+ console.error(message.startsWith("nixamp:") ? message : error);
638
642
  process.exit(1);
639
643
  });
640
644
  }
package/src/owner.ts CHANGED
@@ -104,7 +104,17 @@ export const ADMIN_PATHS = [
104
104
  "/api/admin",
105
105
  ];
106
106
 
107
+ /**
108
+ * Going live and coming back off, which is administering a server.
109
+ *
110
+ * Listed separately from ADMIN_PATHS on purpose: those match by prefix, and
111
+ * `/api/live` itself is the public listen address -- the one the phone line is
112
+ * handed. Gating it by prefix would shut the front door to lock the office.
113
+ */
114
+ const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
115
+
107
116
  export function needsAdmin(path: string, method = "GET"): boolean {
117
+ if (LIVE_CONTROL.includes(path)) return true;
108
118
  if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return true;
109
119
  // Publishing to a channel, or ending one, is administering the server.
110
120
  // Listening to a channel is not: that is what the share link is for.