nixamp 0.7.14 → 0.7.16

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
@@ -147,7 +147,7 @@ publish:
147
147
 
148
148
  ```
149
149
  List this stream at https://nixamp.com/directory so anyone can find it?
150
- It publishes http://198.51.100.7:4321/s/Lk1EM_mP977e1VT — listen only,
150
+ It publishes http://198.51.100.7:4321/v/Lk1EM_mP977e1VT — listen only,
151
151
  not the controls. [Y/n]
152
152
  ```
153
153
 
package/dist/admin.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { createApp, themes } from "@profullstack/hqtui";
9
9
  import { daemonUrl, isLoopbackTls, readState } from "./daemon.js";
10
- import { KEY_HEADER } from "./share.js";
10
+ import { KEY_HEADER, shareLink } from "./share.js";
11
11
  /** Where to point, from the flags or from the daemon that is running. */
12
12
  export function resolveTarget(argv) {
13
13
  const at = argv.findIndex((a) => a === "--url" || a === "-u");
@@ -29,7 +29,7 @@ export function resolveTarget(argv) {
29
29
  " Or administer another machine, with its share link:\n" +
30
30
  " nixamp admin --url https://server1.you.nixamp.com:4321 --key KEY\n" +
31
31
  " The URL and key are the two halves of the link that server printed:\n" +
32
- " https://host:4321/s/KEY");
32
+ " https://host:4321/a/KEY");
33
33
  }
34
34
  const url_ = daemonUrl(state);
35
35
  // Talking to our own daemon, whose certificate names somewhere else. Nothing
@@ -231,7 +231,7 @@ export function draw(ui, theme, view) {
231
231
  const width = Math.max(...view.links.map((link) => link.label.length));
232
232
  ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
233
233
  for (const link of view.links) {
234
- const full = view.key === null ? link.url : `${link.url}/s/${view.key}`;
234
+ const full = shareLink(link.url, view.key);
235
235
  p.text(`${link.label.padEnd(width)} ${full}`, {
236
236
  fg: link.label === "on the internet" ? theme.accent : theme.foreground,
237
237
  });
package/dist/audio.d.ts CHANGED
@@ -17,7 +17,8 @@ export interface Tools {
17
17
  /**
18
18
  * Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
19
19
  * on developer machines and need `mise exec` because the shim itself fails when
20
- * no version is pinned.
20
+ * no version is pinned. Failing both, the places these are actually installed
21
+ * are looked in directly, because a detached daemon's PATH is not the operator's.
21
22
  */
22
23
  export declare function detectTools(): Tools;
23
24
  export declare function probe(tools: Tools, path: string): Track;
package/dist/audio.js CHANGED
@@ -7,6 +7,9 @@
7
7
  * apart within seconds and the bars would stop matching what you hear.
8
8
  */
9
9
  import { spawn, spawnSync } from "node:child_process";
10
+ import { readdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
10
13
  export const RATE = 44100;
11
14
  export const CHANNELS = 2;
12
15
  function works(argv) {
@@ -16,15 +19,55 @@ function works(argv) {
16
19
  const r = spawnSync(cmd, [...rest, "-version"], { encoding: "utf8", timeout: 10_000 });
17
20
  return !r.error && r.status === 0;
18
21
  }
22
+ /**
23
+ * Everywhere a version manager or a package manager tends to leave ffmpeg.
24
+ *
25
+ * A daemon is started detached and inherits whatever environment happened to
26
+ * be around, which on a machine that installs ffmpeg through mise is a PATH
27
+ * with neither `ffmpeg` nor `mise` on it. Every probe then fails, and a
28
+ * failed probe means "no video stream" -- so a television channel arrived as
29
+ * its own soundtrack and nothing anywhere said why. Looking is cheaper than
30
+ * asking somebody to fix their PATH for a process they did not start.
31
+ *
32
+ * Newest first, so a machine with several installed uses the one it would
33
+ * have used anyway.
34
+ */
35
+ function installedElsewhere(name) {
36
+ const home = homedir();
37
+ const roots = [
38
+ join(home, ".local", "share", "mise", "installs", "ffmpeg"),
39
+ join(home, ".asdf", "installs", "ffmpeg"),
40
+ ];
41
+ const found = [];
42
+ for (const root of roots) {
43
+ let versions;
44
+ try {
45
+ versions = readdirSync(root).sort().reverse();
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ for (const version of versions)
51
+ found.push([join(root, version, "bin", name)]);
52
+ }
53
+ // The ordinary absolute places, for a PATH that has been emptied rather than
54
+ // merely shortened.
55
+ for (const dir of ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", "/snap/bin"]) {
56
+ found.push([join(dir, name)]);
57
+ }
58
+ return found;
59
+ }
19
60
  /**
20
61
  * Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
21
62
  * on developer machines and need `mise exec` because the shim itself fails when
22
- * no version is pinned.
63
+ * no version is pinned. Failing both, the places these are actually installed
64
+ * are looked in directly, because a detached daemon's PATH is not the operator's.
23
65
  */
24
66
  export function detectTools() {
25
67
  const candidates = (name) => [
26
68
  [name],
27
69
  ["mise", "exec", `ffmpeg@latest`, "--", name],
70
+ ...installedElsewhere(name),
28
71
  ];
29
72
  const pick = (name) => candidates(name).find((argv) => works(argv)) ?? null;
30
73
  const ffmpeg = pick("ffmpeg");
package/dist/daemon.js CHANGED
@@ -10,7 +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
+ import { portCommands, shareLink } from "./share.js";
14
14
  /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
15
15
  export function stateDir() {
16
16
  const base = process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
@@ -121,7 +121,8 @@ function spell(ms) {
121
121
  * typed the command.
122
122
  */
123
123
  export function daemonLines(state, uptimeMs) {
124
- const link = (url) => (state.key ? `${url}/s/${state.key}` : url);
124
+ // The daemon's own key is the one that administers, so it is an /a/ link.
125
+ const link = (url) => shareLink(url, state.key ?? null);
125
126
  // A state file written by an older nixamp has no list, so host and port
126
127
  // still stand in rather than printing nothing at all.
127
128
  const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
package/dist/main.js CHANGED
@@ -15,6 +15,7 @@ import { version } from "./meta.js";
15
15
  import { displayName, loadSource, loadTagged } from "./playlist.js";
16
16
  import { isRemote } from "./sources.js";
17
17
  import { DEFAULT_PORT } from "./server.js";
18
+ import { shareLink } from "./share.js";
18
19
  const FFT_SIZE = 2048;
19
20
  export const BAND_COUNT = 24;
20
21
  export function createState(tracks, root, silent) {
@@ -510,7 +511,7 @@ export async function main() {
510
511
  const handed = handoff.to;
511
512
  if (handed !== null) {
512
513
  console.log(`Detached. Still playing as pid ${handed.daemon.pid}.`);
513
- console.log(` ${handed.daemon.key ? `${handed.url}/s/${handed.daemon.key}` : handed.url}`);
514
+ console.log(` ${shareLink(handed.url, handed.daemon.key ?? null)}`);
514
515
  console.log(" nixamp attach come back to it");
515
516
  console.log(" nixamp daemon stop when you are done");
516
517
  }
package/dist/server.js CHANGED
@@ -42,7 +42,7 @@ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
42
42
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
43
43
  import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
44
44
  import { codecsOf, probeAsync, videoArgs } from "./audio.js";
45
- import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
45
+ import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyInPath, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
46
46
  import { extname, join, normalize, resolve, sep } from "node:path";
47
47
  import { fileURLToPath } from "node:url";
48
48
  import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
@@ -775,8 +775,9 @@ export function createHandler(engine, options) {
775
775
  // cookie, so every later fetch, EventSource and <audio src> carries it
776
776
  // without the page knowing anything about keys. Either key works here, and
777
777
  // which one was used decides what the browser can then do.
778
- if (key !== null && path.startsWith("/s/")) {
779
- const offered = decodeURIComponent(path.slice("/s/".length));
778
+ const offeredInPath = key !== null ? keyInPath(path) : null;
779
+ if (offeredInPath !== null) {
780
+ const offered = offeredInPath;
780
781
  if (scopeOf(offered, key, listenKey) === null) {
781
782
  json(response, 404, { error: "not found" });
782
783
  return;
@@ -2644,7 +2645,7 @@ export async function serve(argv, version = "0.1.0") {
2644
2645
  live: publisher !== null,
2645
2646
  code: listing?.code ?? "",
2646
2647
  name: listing?.name ?? (options.name || hostname()),
2647
- url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
2648
+ url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
2648
2649
  // Whether going live is even possible here. A laptop behind a router
2649
2650
  // with no address the world can reach cannot be listed, and a button
2650
2651
  // that could only fail is worse than one that is not offered.
@@ -2829,7 +2830,7 @@ export async function serve(argv, version = "0.1.0") {
2829
2830
  for (const { label, url } of addresses) {
2830
2831
  if (label === "here")
2831
2832
  continue;
2832
- console.log(` ${shareLink(url, listenKey)}`);
2833
+ console.log(` ${shareLink(url, listenKey, false)}`);
2833
2834
  }
2834
2835
  }
2835
2836
  }
@@ -2926,7 +2927,7 @@ export async function serve(argv, version = "0.1.0") {
2926
2927
  function makePublisher() {
2927
2928
  if (!publishable_)
2928
2929
  return null;
2929
- const listen = shareLink(publishable_.url, listenKey);
2930
+ const listen = shareLink(publishable_.url, listenKey, false);
2930
2931
  // Announced next to the listen link, not instead of it: one is for a person
2931
2932
  // with a browser, the other for the phone line and anything else that is
2932
2933
  // handed one address and expected to play it.
@@ -2964,7 +2965,7 @@ export async function serve(argv, version = "0.1.0") {
2964
2965
  });
2965
2966
  }
2966
2967
  if (options.publish !== "no" && publishable_) {
2967
- const listen = shareLink(publishable_.url, listenKey);
2968
+ const listen = shareLink(publishable_.url, listenKey, false);
2968
2969
  const wanted = options.publish === "yes"
2969
2970
  ? true
2970
2971
  : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
@@ -2981,6 +2982,33 @@ export async function serve(argv, version = "0.1.0") {
2981
2982
  console.log("");
2982
2983
  console.log(" --publish needs an address the world can reach. This machine has none.");
2983
2984
  }
2985
+ // Remember this machine on the account it belongs to, without being asked.
2986
+ //
2987
+ // `nixamp server add --here` existed and did exactly this, which is a manual
2988
+ // step for something the daemon already knows: it has just worked out its
2989
+ // own address and minted its own key, and the session file says whose it is.
2990
+ // Somebody who has signed in on this machine has said which account it is;
2991
+ // being on their list is what they meant by that.
2992
+ //
2993
+ // Idempotent: adding the same URL again updates the row rather than making
2994
+ // a second one, so this is safe on every start. Silent about failure, since
2995
+ // nothing here is worth stopping a player for.
2996
+ if (session?.token && publishable_) {
2997
+ const remembered = shareLink(publishable_.url, key);
2998
+ void fetch(`${DEFAULT_DIRECTORY}/api/v1/servers`, {
2999
+ method: "POST",
3000
+ headers: { "content-type": "application/json", authorization: `Bearer ${session.token}` },
3001
+ body: JSON.stringify({ url: publishable_.url, name: options.name || hostname(), key: key ?? "" }),
3002
+ })
3003
+ .then((answer) => {
3004
+ if (answer.ok)
3005
+ console.log(` Remembered on your account at ${DEFAULT_DIRECTORY}.`);
3006
+ })
3007
+ .catch(() => {
3008
+ // The directory being unreachable is not a reason to stop serving.
3009
+ });
3010
+ void remembered;
3011
+ }
2984
3012
  // A last resort, not a licence.
2985
3013
  //
2986
3014
  // Every throw reachable from a request should be caught where it happens,
package/dist/session.js CHANGED
@@ -23,6 +23,7 @@ import { createInterface } from "node:readline/promises";
23
23
  import { dirname, join } from "node:path";
24
24
  import { stateDir } from "./daemon.js";
25
25
  import { DEFAULT_DIRECTORY } from "./directory.js";
26
+ import { shareLink } from "./share.js";
26
27
  export function sessionPath() {
27
28
  return join(stateDir(), "session.json");
28
29
  }
@@ -514,7 +515,7 @@ export async function servers(argv, fetcher = fetch) {
514
515
  if (state === null) {
515
516
  console.error("nixamp: no daemon is running here.\n" +
516
517
  " Start one: nixamp daemon start ~/Music\n" +
517
- " Or give the share link of the one you mean: https://host:4321/s/KEY");
518
+ " Or give the share link of the one you mean: https://host:4321/a/KEY");
518
519
  return 1;
519
520
  }
520
521
  // The address worth remembering is the one somebody else can open.
@@ -567,7 +568,7 @@ export async function servers(argv, fetcher = fetch) {
567
568
  }
568
569
  const width = Math.max(...rows.map((row) => row.name.length));
569
570
  for (const row of rows) {
570
- const link = row.key ? `${row.url}/s/${row.key}` : row.url;
571
+ const link = shareLink(row.url, row.key ?? null);
571
572
  console.log(`${row.id} ${row.name.padEnd(width)} ${link}`);
572
573
  }
573
574
  return 0;
package/dist/share.d.ts CHANGED
@@ -84,8 +84,20 @@ export declare function reachableAddresses(host: string, port: number, publicUrl
84
84
  label: string;
85
85
  url: string;
86
86
  }[];
87
- /** The full link, key and all. */
88
- export declare function shareLink(base: string, key: string | null): string;
87
+ /**
88
+ * The two paths a key can arrive on, and what each one says about itself.
89
+ *
90
+ * `/a/` administers and `/v/` only views. There used to be a `/s/` that meant
91
+ * neither -- just "here is a key" -- which is why somebody handed one of two
92
+ * identical-looking links had no way to tell which they were holding, and
93
+ * reported the controls as missing when they were never going to be there.
94
+ * A link should say what it is before anybody clicks it.
95
+ */
96
+ export declare const KEY_PATHS: readonly ["/a/", "/v/"];
97
+ /** The key in a share link, whichever of the three shapes it came in. */
98
+ export declare function keyInPath(path: string): string | null;
99
+ /** The full link, key and all. `admin` picks the shape that says which it is. */
100
+ export declare function shareLink(base: string, key: string | null, admin?: boolean): string;
89
101
  /**
90
102
  * The same stream, as bytes rather than as a page.
91
103
  *
package/dist/share.js CHANGED
@@ -241,9 +241,36 @@ export function reachableAddresses(host, port, publicUrl = "", scheme = "http")
241
241
  .map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
242
242
  ];
243
243
  }
244
- /** The full link, key and all. */
245
- export function shareLink(base, key) {
246
- return key === null ? base : `${base}/s/${key}`;
244
+ /**
245
+ * The two paths a key can arrive on, and what each one says about itself.
246
+ *
247
+ * `/a/` administers and `/v/` only views. There used to be a `/s/` that meant
248
+ * neither -- just "here is a key" -- which is why somebody handed one of two
249
+ * identical-looking links had no way to tell which they were holding, and
250
+ * reported the controls as missing when they were never going to be there.
251
+ * A link should say what it is before anybody clicks it.
252
+ */
253
+ export const KEY_PATHS = ["/a/", "/v/"];
254
+ /** The key in a share link, whichever of the three shapes it came in. */
255
+ export function keyInPath(path) {
256
+ for (const prefix of KEY_PATHS) {
257
+ if (!path.startsWith(prefix))
258
+ continue;
259
+ const rest = path.slice(prefix.length).replace(/\/+$/, "");
260
+ if (rest === "" || rest.includes("/"))
261
+ return null;
262
+ try {
263
+ return decodeURIComponent(rest);
264
+ }
265
+ catch {
266
+ return rest;
267
+ }
268
+ }
269
+ return null;
270
+ }
271
+ /** The full link, key and all. `admin` picks the shape that says which it is. */
272
+ export function shareLink(base, key, admin = true) {
273
+ return key === null ? base : `${base}${admin ? "/a/" : "/v/"}${key}`;
247
274
  }
248
275
  /**
249
276
  * The same stream, as bytes rather than as a page.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
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
@@ -9,7 +9,7 @@ import { createApp, themes, type Container, type KeyEvent, type Theme } from "@p
9
9
  import type { Color } from "@profullstack/hqtui";
10
10
  import type { Connection } from "./connections.ts";
11
11
  import { daemonUrl, isLoopbackTls, readState } from "./daemon.ts";
12
- import { KEY_HEADER } from "./share.ts";
12
+ import { KEY_HEADER, shareLink } from "./share.ts";
13
13
 
14
14
  interface Report {
15
15
  connections: Connection[];
@@ -66,7 +66,7 @@ export function resolveTarget(argv: string[]): AdminOptions {
66
66
  " Or administer another machine, with its share link:\n" +
67
67
  " nixamp admin --url https://server1.you.nixamp.com:4321 --key KEY\n" +
68
68
  " The URL and key are the two halves of the link that server printed:\n" +
69
- " https://host:4321/s/KEY",
69
+ " https://host:4321/a/KEY",
70
70
  );
71
71
  }
72
72
  const url_ = daemonUrl(state);
@@ -278,7 +278,7 @@ export function draw(ui: Container, theme: Theme, view: View): void {
278
278
  const width = Math.max(...view.links.map((link) => link.label.length));
279
279
  ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
280
280
  for (const link of view.links) {
281
- const full = view.key === null ? link.url : `${link.url}/s/${view.key}`;
281
+ const full = shareLink(link.url, view.key);
282
282
  p.text(`${link.label.padEnd(width)} ${full}`, {
283
283
  fg: link.label === "on the internet" ? theme.accent : theme.foreground,
284
284
  });
package/src/audio.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  * apart within seconds and the bars would stop matching what you hear.
8
8
  */
9
9
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
10
+ import { readdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
10
13
 
11
14
  export const RATE = 44100;
12
15
  export const CHANNELS = 2;
@@ -34,15 +37,54 @@ function works(argv: string[]): boolean {
34
37
  return !r.error && r.status === 0;
35
38
  }
36
39
 
40
+ /**
41
+ * Everywhere a version manager or a package manager tends to leave ffmpeg.
42
+ *
43
+ * A daemon is started detached and inherits whatever environment happened to
44
+ * be around, which on a machine that installs ffmpeg through mise is a PATH
45
+ * with neither `ffmpeg` nor `mise` on it. Every probe then fails, and a
46
+ * failed probe means "no video stream" -- so a television channel arrived as
47
+ * its own soundtrack and nothing anywhere said why. Looking is cheaper than
48
+ * asking somebody to fix their PATH for a process they did not start.
49
+ *
50
+ * Newest first, so a machine with several installed uses the one it would
51
+ * have used anyway.
52
+ */
53
+ function installedElsewhere(name: string): string[][] {
54
+ const home = homedir();
55
+ const roots = [
56
+ join(home, ".local", "share", "mise", "installs", "ffmpeg"),
57
+ join(home, ".asdf", "installs", "ffmpeg"),
58
+ ];
59
+ const found: string[][] = [];
60
+ for (const root of roots) {
61
+ let versions: string[];
62
+ try {
63
+ versions = readdirSync(root).sort().reverse();
64
+ } catch {
65
+ continue;
66
+ }
67
+ for (const version of versions) found.push([join(root, version, "bin", name)]);
68
+ }
69
+ // The ordinary absolute places, for a PATH that has been emptied rather than
70
+ // merely shortened.
71
+ for (const dir of ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", "/snap/bin"]) {
72
+ found.push([join(dir, name)]);
73
+ }
74
+ return found;
75
+ }
76
+
37
77
  /**
38
78
  * Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
39
79
  * on developer machines and need `mise exec` because the shim itself fails when
40
- * no version is pinned.
80
+ * no version is pinned. Failing both, the places these are actually installed
81
+ * are looked in directly, because a detached daemon's PATH is not the operator's.
41
82
  */
42
83
  export function detectTools(): Tools {
43
84
  const candidates = (name: string): string[][] => [
44
85
  [name],
45
86
  ["mise", "exec", `ffmpeg@latest`, "--", name],
87
+ ...installedElsewhere(name),
46
88
  ];
47
89
  const pick = (name: string): string[] | null =>
48
90
  candidates(name).find((argv) => works(argv)) ?? null;
package/src/daemon.ts CHANGED
@@ -10,7 +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
+ import { type Firewall, portCommands, shareLink } from "./share.ts";
14
14
 
15
15
  export interface DaemonState {
16
16
  pid: number;
@@ -164,7 +164,8 @@ function spell(ms: number): string {
164
164
  * typed the command.
165
165
  */
166
166
  export function daemonLines(state: DaemonState, uptimeMs?: number): string[] {
167
- const link = (url: string): string => (state.key ? `${url}/s/${state.key}` : url);
167
+ // The daemon's own key is the one that administers, so it is an /a/ link.
168
+ const link = (url: string): string => shareLink(url, state.key ?? null);
168
169
  // A state file written by an older nixamp has no list, so host and port
169
170
  // still stand in rather than printing nothing at all.
170
171
  const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
package/src/main.ts CHANGED
@@ -19,6 +19,7 @@ import { displayName, loadSource, loadTagged } from "./playlist.ts";
19
19
  import { isRemote } from "./sources.ts";
20
20
  import { DEFAULT_PORT } from "./server.ts";
21
21
  import type { DaemonState } from "./daemon.ts";
22
+ import { shareLink } from "./share.ts";
22
23
 
23
24
  const FFT_SIZE = 2048;
24
25
  export const BAND_COUNT = 24;
@@ -515,7 +516,7 @@ export async function main(): Promise<void> {
515
516
  const handed = handoff.to;
516
517
  if (handed !== null) {
517
518
  console.log(`Detached. Still playing as pid ${handed.daemon.pid}.`);
518
- console.log(` ${handed.daemon.key ? `${handed.url}/s/${handed.daemon.key}` : handed.url}`);
519
+ console.log(` ${shareLink(handed.url, handed.daemon.key ?? null)}`);
519
520
  console.log(" nixamp attach come back to it");
520
521
  console.log(" nixamp daemon stop when you are done");
521
522
  }
package/src/server.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  firewallInUse,
69
69
  certifiable,
70
70
  keyCookie,
71
+ keyInPath,
71
72
  rememberedKeys,
72
73
  keyFrom,
73
74
  keysMatch,
@@ -1073,8 +1074,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1073
1074
  // cookie, so every later fetch, EventSource and <audio src> carries it
1074
1075
  // without the page knowing anything about keys. Either key works here, and
1075
1076
  // which one was used decides what the browser can then do.
1076
- if (key !== null && path.startsWith("/s/")) {
1077
- const offered = decodeURIComponent(path.slice("/s/".length));
1077
+ const offeredInPath = key !== null ? keyInPath(path) : null;
1078
+ if (offeredInPath !== null) {
1079
+ const offered = offeredInPath;
1078
1080
  if (scopeOf(offered, key, listenKey) === null) {
1079
1081
  json(response, 404, { error: "not found" });
1080
1082
  return;
@@ -3078,7 +3080,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3078
3080
  live: publisher !== null,
3079
3081
  code: listing?.code ?? "",
3080
3082
  name: listing?.name ?? (options.name || hostname()),
3081
- url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
3083
+ url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
3082
3084
  // Whether going live is even possible here. A laptop behind a router
3083
3085
  // with no address the world can reach cannot be listed, and a button
3084
3086
  // that could only fail is worse than one that is not offered.
@@ -3285,7 +3287,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3285
3287
  console.log(" A listen-only link, for someone you want to hear it but not drive it:");
3286
3288
  for (const { label, url } of addresses) {
3287
3289
  if (label === "here") continue;
3288
- console.log(` ${shareLink(url, listenKey)}`);
3290
+ console.log(` ${shareLink(url, listenKey, false)}`);
3289
3291
  }
3290
3292
  }
3291
3293
  }
@@ -3382,7 +3384,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3382
3384
  */
3383
3385
  function makePublisher(): Publisher | null {
3384
3386
  if (!publishable_) return null;
3385
- const listen = shareLink(publishable_.url, listenKey);
3387
+ const listen = shareLink(publishable_.url, listenKey, false);
3386
3388
  // Announced next to the listen link, not instead of it: one is for a person
3387
3389
  // with a browser, the other for the phone line and anything else that is
3388
3390
  // handed one address and expected to play it.
@@ -3420,7 +3422,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3420
3422
  }
3421
3423
 
3422
3424
  if (options.publish !== "no" && publishable_) {
3423
- const listen = shareLink(publishable_.url, listenKey);
3425
+ const listen = shareLink(publishable_.url, listenKey, false);
3424
3426
  const wanted = options.publish === "yes"
3425
3427
  ? true
3426
3428
  : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
@@ -3438,6 +3440,33 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3438
3440
  console.log(" --publish needs an address the world can reach. This machine has none.");
3439
3441
  }
3440
3442
 
3443
+ // Remember this machine on the account it belongs to, without being asked.
3444
+ //
3445
+ // `nixamp server add --here` existed and did exactly this, which is a manual
3446
+ // step for something the daemon already knows: it has just worked out its
3447
+ // own address and minted its own key, and the session file says whose it is.
3448
+ // Somebody who has signed in on this machine has said which account it is;
3449
+ // being on their list is what they meant by that.
3450
+ //
3451
+ // Idempotent: adding the same URL again updates the row rather than making
3452
+ // a second one, so this is safe on every start. Silent about failure, since
3453
+ // nothing here is worth stopping a player for.
3454
+ if (session?.token && publishable_) {
3455
+ const remembered = shareLink(publishable_.url, key);
3456
+ void fetch(`${DEFAULT_DIRECTORY}/api/v1/servers`, {
3457
+ method: "POST",
3458
+ headers: { "content-type": "application/json", authorization: `Bearer ${session.token}` },
3459
+ body: JSON.stringify({ url: publishable_.url, name: options.name || hostname(), key: key ?? "" }),
3460
+ })
3461
+ .then((answer) => {
3462
+ if (answer.ok) console.log(` Remembered on your account at ${DEFAULT_DIRECTORY}.`);
3463
+ })
3464
+ .catch(() => {
3465
+ // The directory being unreachable is not a reason to stop serving.
3466
+ });
3467
+ void remembered;
3468
+ }
3469
+
3441
3470
  // A last resort, not a licence.
3442
3471
  //
3443
3472
  // Every throw reachable from a request should be caught where it happens,
package/src/session.ts CHANGED
@@ -23,6 +23,7 @@ import { createInterface } from "node:readline/promises";
23
23
  import { dirname, join } from "node:path";
24
24
  import { stateDir } from "./daemon.ts";
25
25
  import { DEFAULT_DIRECTORY } from "./directory.ts";
26
+ import { shareLink } from "./share.ts";
26
27
 
27
28
  export interface Session {
28
29
  site: string;
@@ -585,7 +586,7 @@ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Pr
585
586
  console.error(
586
587
  "nixamp: no daemon is running here.\n" +
587
588
  " Start one: nixamp daemon start ~/Music\n" +
588
- " Or give the share link of the one you mean: https://host:4321/s/KEY",
589
+ " Or give the share link of the one you mean: https://host:4321/a/KEY",
589
590
  );
590
591
  return 1;
591
592
  }
@@ -642,7 +643,7 @@ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Pr
642
643
  }
643
644
  const width = Math.max(...rows.map((row) => row.name.length));
644
645
  for (const row of rows) {
645
- const link = row.key ? `${row.url}/s/${row.key}` : row.url;
646
+ const link = shareLink(row.url, row.key ?? null);
646
647
  console.log(`${row.id} ${row.name.padEnd(width)} ${link}`);
647
648
  }
648
649
  return 0;
package/src/share.ts CHANGED
@@ -271,9 +271,35 @@ export function reachableAddresses(
271
271
  ];
272
272
  }
273
273
 
274
- /** The full link, key and all. */
275
- export function shareLink(base: string, key: string | null): string {
276
- return key === null ? base : `${base}/s/${key}`;
274
+ /**
275
+ * The two paths a key can arrive on, and what each one says about itself.
276
+ *
277
+ * `/a/` administers and `/v/` only views. There used to be a `/s/` that meant
278
+ * neither -- just "here is a key" -- which is why somebody handed one of two
279
+ * identical-looking links had no way to tell which they were holding, and
280
+ * reported the controls as missing when they were never going to be there.
281
+ * A link should say what it is before anybody clicks it.
282
+ */
283
+ export const KEY_PATHS = ["/a/", "/v/"] as const;
284
+
285
+ /** The key in a share link, whichever of the three shapes it came in. */
286
+ export function keyInPath(path: string): string | null {
287
+ for (const prefix of KEY_PATHS) {
288
+ if (!path.startsWith(prefix)) continue;
289
+ const rest = path.slice(prefix.length).replace(/\/+$/, "");
290
+ if (rest === "" || rest.includes("/")) return null;
291
+ try {
292
+ return decodeURIComponent(rest);
293
+ } catch {
294
+ return rest;
295
+ }
296
+ }
297
+ return null;
298
+ }
299
+
300
+ /** The full link, key and all. `admin` picks the shape that says which it is. */
301
+ export function shareLink(base: string, key: string | null, admin = true): string {
302
+ return key === null ? base : `${base}${admin ? "/a/" : "/v/"}${key}`;
277
303
  }
278
304
 
279
305
  /**
@@ -1 +1 @@
1
- import{t as e}from"./index-eSMbwh8n.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
1
+ import{t as e}from"./index-BIeMVcOg.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};