nixamp 0.7.8 → 0.7.10

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.
@@ -53,5 +53,27 @@ export declare function loadPlaylist(tools: Tools, root: string, probeTags?: boo
53
53
  */
54
54
  export declare function loadTagged(tools: Tools, source: string,
55
55
  /** Injected by the test, which must not depend on ffprobe being installed. */
56
- probeOne?: (tools: Tools, path: string) => Promise<Track>): Promise<Track[]>;
56
+ probeOne?: (tools: Tools, path: string) => Promise<Track>,
57
+ /**
58
+ * The files, when the caller has already found them.
59
+ *
60
+ * Startup walks the library to list it and then walked it again to tag it --
61
+ * twice through a large tree, and the second walk was the one that happened
62
+ * after the port was open, so it was the one people waited on.
63
+ */
64
+ known?: string[]): Promise<Track[]>;
65
+ /**
66
+ * The same walk, without stopping everything for the length of it.
67
+ *
68
+ * `findAudio` is readdirSync and statSync all the way down, so on a large
69
+ * library it holds the event loop for its entire duration: the socket keeps
70
+ * accepting connections, the kernel completes their handshakes, and the
71
+ * process answers none of them. From outside that is indistinguishable from a
72
+ * server that has hung -- 417 gigabytes of downloads took long enough that
73
+ * requests timed out while the log said the server was up.
74
+ *
75
+ * Yielding every few hundred entries costs a few milliseconds over the whole
76
+ * walk and means a request waits for one directory rather than for the disk.
77
+ */
78
+ export declare function findAudioAsync(root: string, every?: number): Promise<string[]>;
57
79
  export declare function displayName(track: Track): string;
package/dist/playlist.js CHANGED
@@ -180,7 +180,11 @@ export async function loadSource(tools, source, probeTags = true) {
180
180
  // stream is ffmpeg's problem, and it is good at it.
181
181
  return [bare({ source, title: nameOf(source), duration: 0 })];
182
182
  }
183
- return loadPlaylist(tools, source, probeTags);
183
+ // The walk yields, because by the time this runs at startup the port is
184
+ // already open and a synchronous walk of a large library answers nobody for
185
+ // as long as it takes.
186
+ const paths = await findAudioAsync(source);
187
+ return paths.map((path) => probeTags ? probe(tools, path) : { path, title: path.split("/").pop() ?? path, artist: "", album: "", duration: 0 });
184
188
  }
185
189
  /**
186
190
  * Reading tags means an ffprobe per file, which is slow for a large library, so
@@ -209,11 +213,19 @@ export function loadPlaylist(tools, root, probeTags = true) {
209
213
  */
210
214
  export async function loadTagged(tools, source,
211
215
  /** Injected by the test, which must not depend on ffprobe being installed. */
212
- probeOne = probeAsync) {
216
+ probeOne = probeAsync,
217
+ /**
218
+ * The files, when the caller has already found them.
219
+ *
220
+ * Startup walks the library to list it and then walked it again to tag it --
221
+ * twice through a large tree, and the second walk was the one that happened
222
+ * after the port was open, so it was the one people waited on.
223
+ */
224
+ known) {
213
225
  // A URL is one thing and is never probed; a playlist carries its own titles.
214
226
  if (isRemote(source) || isPlaylistFile(source))
215
227
  return loadSource(tools, source, true);
216
- const paths = findAudio(source);
228
+ const paths = known ?? (await findAudioAsync(source));
217
229
  const tracks = [];
218
230
  for (const path of paths) {
219
231
  // Awaiting a child process, not blocking on one. Yielding between files
@@ -224,6 +236,66 @@ probeOne = probeAsync) {
224
236
  }
225
237
  return tracks;
226
238
  }
239
+ /**
240
+ * The same walk, without stopping everything for the length of it.
241
+ *
242
+ * `findAudio` is readdirSync and statSync all the way down, so on a large
243
+ * library it holds the event loop for its entire duration: the socket keeps
244
+ * accepting connections, the kernel completes their handshakes, and the
245
+ * process answers none of them. From outside that is indistinguishable from a
246
+ * server that has hung -- 417 gigabytes of downloads took long enough that
247
+ * requests timed out while the log said the server was up.
248
+ *
249
+ * Yielding every few hundred entries costs a few milliseconds over the whole
250
+ * walk and means a request waits for one directory rather than for the disk.
251
+ */
252
+ export async function findAudioAsync(root, every = 200) {
253
+ const out = [];
254
+ let stats;
255
+ try {
256
+ stats = statSync(root);
257
+ }
258
+ catch {
259
+ return out;
260
+ }
261
+ if (stats.isFile())
262
+ return isAudio(root) ? [root] : out;
263
+ let since = 0;
264
+ const breathe = async () => {
265
+ if (++since < every)
266
+ return;
267
+ since = 0;
268
+ await new Promise((done) => setImmediate(done));
269
+ };
270
+ const walk = async (dir) => {
271
+ let entries;
272
+ try {
273
+ entries = readdirSync(dir).sort();
274
+ }
275
+ catch {
276
+ return;
277
+ }
278
+ for (const entry of entries) {
279
+ if (entry.startsWith("."))
280
+ continue;
281
+ const full = join(dir, entry);
282
+ await breathe();
283
+ let stat;
284
+ try {
285
+ stat = statSync(full);
286
+ }
287
+ catch {
288
+ continue;
289
+ }
290
+ if (stat.isDirectory())
291
+ await walk(full);
292
+ else if (isAudio(full))
293
+ out.push(full);
294
+ }
295
+ };
296
+ await walk(root);
297
+ return out;
298
+ }
227
299
  export function displayName(track) {
228
300
  return track.artist ? `${track.artist} — ${track.title}` : track.title;
229
301
  }
package/dist/server.d.ts CHANGED
@@ -29,6 +29,8 @@ export interface ServeOptions {
29
29
  * port, which is what the public deployment wants and no private one does.
30
30
  */
31
31
  key: boolean;
32
+ /** Mint a new share key rather than reusing the one this port had. */
33
+ newKey: boolean;
32
34
  /**
33
35
  * Ask the local firewall to let the port through, and put it back on the way
34
36
  * out. Off by default because it changes the machine, not just this process.
package/dist/server.js CHANGED
@@ -29,6 +29,7 @@ import { DeviceGrants } from "./device.js";
29
29
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
30
30
  import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
31
31
  import { needsAdmin, Owner } from "./owner.js";
32
+ import { stateDir } from "./daemon.js";
32
33
  import { readSession } from "./session.js";
33
34
  import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
34
35
  import { PartyLine, telnyxSms } from "./partyline.js";
@@ -40,8 +41,8 @@ import { notifyAll, resendEmail, webPush } from "./notify.js";
40
41
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
41
42
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
42
43
  import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
43
- import { codecsOf, videoArgs } from "./audio.js";
44
- import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
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
46
  import { extname, join, normalize, resolve, sep } from "node:path";
46
47
  import { fileURLToPath } from "node:url";
47
48
  import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
@@ -68,6 +69,7 @@ export function parseServeArgs(argv) {
68
69
  web: null,
69
70
  media: true,
70
71
  key: true,
72
+ newKey: false,
71
73
  openPort: false,
72
74
  announce: false,
73
75
  directory: false,
@@ -157,6 +159,9 @@ export function parseServeArgs(argv) {
157
159
  else if (arg === "--owner") {
158
160
  options.owner = value();
159
161
  }
162
+ else if (arg === "--new-key") {
163
+ options.newKey = true;
164
+ }
160
165
  else if (arg === "--ingest") {
161
166
  options.ingest = true;
162
167
  }
@@ -1852,6 +1857,9 @@ export function createHandler(engine, options) {
1852
1857
  // Where to point OBS. Printed at startup since RTMP was added, which
1853
1858
  // is no use at all to somebody looking at the admin panel a day later.
1854
1859
  publish: options.publishUrls?.() ?? [],
1860
+ // And which of those slots somebody is already on, because the
1861
+ // question you have in front of three addresses is which one is free.
1862
+ channels: options.channels?.list().map(({ id, name, via }) => ({ id, name, via })) ?? [],
1855
1863
  });
1856
1864
  return;
1857
1865
  }
@@ -2402,10 +2410,15 @@ export async function serve(argv, version = "0.1.0") {
2402
2410
  // the slowest part of starting and nothing about it needs to happen first.
2403
2411
  const engine = new PlayerEngine([], root, tools);
2404
2412
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
2405
- const key = options.key ? newKey() : null;
2406
- // Minted whether or not it is published, so `nixamp admin` and the operator
2413
+ // The same keys this port used last time, so a link somebody was given
2414
+ // still works after a restart -- and a server is restarted to pick up a new
2415
+ // version, which is to say often. `--new-key` mints a fresh pair and forgets
2416
+ // the old one, which is the way to revoke a link that got out.
2417
+ const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
2418
+ const key = remembered?.key ?? null;
2419
+ // Kept whether or not it is published, so `nixamp admin` and the operator
2407
2420
  // both have a link they can hand out without handing over the controls.
2408
- const listenKey = key === null ? null : newKey();
2421
+ const listenKey = remembered?.listenKey ?? null;
2409
2422
  // Configuration can arrive from the directory later, so it is a box the
2410
2423
  // paywall reads rather than a value it was handed once.
2411
2424
  let paywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
@@ -2768,7 +2781,10 @@ export async function serve(argv, version = "0.1.0") {
2768
2781
  console.log(`nixamp serve — ${found.length} tracks under ${root}`);
2769
2782
  if (isRemote(root))
2770
2783
  return;
2771
- return loadTagged(tools, root)
2784
+ // Handed the files we already found. Tagging used to walk the whole
2785
+ // library a second time to discover the same paths, and that second walk
2786
+ // was the one that ran with the port already open.
2787
+ return loadTagged(tools, root, probeAsync, found.map((track) => track.path))
2772
2788
  .then((tagged) => engine.retag(tagged, root))
2773
2789
  .catch(() => {
2774
2790
  // Filenames are a working player. A failure here is worth nothing
package/dist/share.d.ts CHANGED
@@ -17,6 +17,31 @@ export declare const KEY_HEADER = "x-nixamp-key";
17
17
  * enough to read down a phone screen when someone types it by hand.
18
18
  */
19
19
  export declare function newKey(): string;
20
+ /** The pair of keys a server hands out: one that drives, one that only hears. */
21
+ export interface KeyPair {
22
+ key: string;
23
+ listenKey: string;
24
+ }
25
+ /**
26
+ * The keys this port used last time, or a new pair remembered for next time.
27
+ *
28
+ * Keys used to be minted on every start, so every link anybody had been given
29
+ * died the moment the server was restarted -- and a server gets restarted to
30
+ * pick up a new version, which is to say often. A link you cannot rely on is
31
+ * not a link you can share, which was most of why sharing did not feel like it
32
+ * worked.
33
+ *
34
+ * Kept per port, because two servers on one machine are two different
35
+ * audiences, and a single remembered key would hand each of them the other's.
36
+ *
37
+ * The trade is that a key which never changes is a key that stays valid if it
38
+ * leaks, so `fresh` mints a new pair and forgets the old one -- which is what
39
+ * `--new-key` is for.
40
+ */
41
+ export declare function rememberedKeys(dir: string, port: number, fresh?: boolean, io?: {
42
+ read: (path: string) => string | null;
43
+ write: (path: string, body: string) => void;
44
+ }): KeyPair;
20
45
  /** Compare without leaking where two keys first differ. */
21
46
  export declare function keysMatch(a: string, b: string): boolean;
22
47
  /** Every place a key is accepted from, in the order they are looked for. */
package/dist/share.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * every EventSource and every `<audio src>` on its own.
12
12
  */
13
13
  import { randomBytes, timingSafeEqual } from "node:crypto";
14
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname } from "node:path";
14
16
  import { networkInterfaces } from "node:os";
15
17
  /** The cookie, and the query parameter that sets it. */
16
18
  export const KEY_COOKIE = "nixamp_key";
@@ -23,6 +25,66 @@ export const KEY_HEADER = "x-nixamp-key";
23
25
  export function newKey() {
24
26
  return randomBytes(16).toString("base64url");
25
27
  }
28
+ /**
29
+ * The keys this port used last time, or a new pair remembered for next time.
30
+ *
31
+ * Keys used to be minted on every start, so every link anybody had been given
32
+ * died the moment the server was restarted -- and a server gets restarted to
33
+ * pick up a new version, which is to say often. A link you cannot rely on is
34
+ * not a link you can share, which was most of why sharing did not feel like it
35
+ * worked.
36
+ *
37
+ * Kept per port, because two servers on one machine are two different
38
+ * audiences, and a single remembered key would hand each of them the other's.
39
+ *
40
+ * The trade is that a key which never changes is a key that stays valid if it
41
+ * leaks, so `fresh` mints a new pair and forgets the old one -- which is what
42
+ * `--new-key` is for.
43
+ */
44
+ export function rememberedKeys(dir, port, fresh = false, io = defaultKeyStore) {
45
+ const path = `${dir}/keys.json`;
46
+ let all = {};
47
+ const existing = io.read(path);
48
+ if (existing !== null) {
49
+ try {
50
+ const parsed = JSON.parse(existing);
51
+ if (parsed && typeof parsed === "object")
52
+ all = parsed;
53
+ }
54
+ catch {
55
+ // A file we cannot read is a file we replace. Losing a key costs a link;
56
+ // refusing to start costs the whole server.
57
+ }
58
+ }
59
+ const held = all[String(port)];
60
+ if (!fresh && held && typeof held.key === "string" && typeof held.listenKey === "string")
61
+ return held;
62
+ const minted = { key: newKey(), listenKey: newKey() };
63
+ all[String(port)] = minted;
64
+ try {
65
+ io.write(path, JSON.stringify(all, null, 2));
66
+ }
67
+ catch {
68
+ // Unwritable state is a key that will not survive a restart, which is how
69
+ // it behaved before this existed. Not a reason to refuse to serve.
70
+ }
71
+ return minted;
72
+ }
73
+ const defaultKeyStore = {
74
+ read: (path) => {
75
+ try {
76
+ return readFileSync(path, "utf8");
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ },
82
+ write: (path, body) => {
83
+ mkdirSync(dirname(path), { recursive: true });
84
+ // Readable only by its owner: it is the password to this server.
85
+ writeFileSync(path, body, { mode: 0o600 });
86
+ },
87
+ };
26
88
  /** Compare without leaking where two keys first differ. */
27
89
  export function keysMatch(a, b) {
28
90
  const left = Buffer.from(a);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
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/playlist.ts CHANGED
@@ -183,7 +183,13 @@ export async function loadSource(tools: Tools, source: string, probeTags = true)
183
183
  return [bare({ source, title: nameOf(source), duration: 0 })];
184
184
  }
185
185
 
186
- return loadPlaylist(tools, source, probeTags);
186
+ // The walk yields, because by the time this runs at startup the port is
187
+ // already open and a synchronous walk of a large library answers nobody for
188
+ // as long as it takes.
189
+ const paths = await findAudioAsync(source);
190
+ return paths.map((path) =>
191
+ probeTags ? probe(tools, path) : { path, title: path.split("/").pop() ?? path, artist: "", album: "", duration: 0 },
192
+ );
187
193
  }
188
194
 
189
195
  /**
@@ -218,11 +224,19 @@ export async function loadTagged(
218
224
  source: string,
219
225
  /** Injected by the test, which must not depend on ffprobe being installed. */
220
226
  probeOne: (tools: Tools, path: string) => Promise<Track> = probeAsync,
227
+ /**
228
+ * The files, when the caller has already found them.
229
+ *
230
+ * Startup walks the library to list it and then walked it again to tag it --
231
+ * twice through a large tree, and the second walk was the one that happened
232
+ * after the port was open, so it was the one people waited on.
233
+ */
234
+ known?: string[],
221
235
  ): Promise<Track[]> {
222
236
  // A URL is one thing and is never probed; a playlist carries its own titles.
223
237
  if (isRemote(source) || isPlaylistFile(source)) return loadSource(tools, source, true);
224
238
 
225
- const paths = findAudio(source);
239
+ const paths = known ?? (await findAudioAsync(source));
226
240
  const tracks: Track[] = [];
227
241
  for (const path of paths) {
228
242
  // Awaiting a child process, not blocking on one. Yielding between files
@@ -234,6 +248,61 @@ export async function loadTagged(
234
248
  return tracks;
235
249
  }
236
250
 
251
+ /**
252
+ * The same walk, without stopping everything for the length of it.
253
+ *
254
+ * `findAudio` is readdirSync and statSync all the way down, so on a large
255
+ * library it holds the event loop for its entire duration: the socket keeps
256
+ * accepting connections, the kernel completes their handshakes, and the
257
+ * process answers none of them. From outside that is indistinguishable from a
258
+ * server that has hung -- 417 gigabytes of downloads took long enough that
259
+ * requests timed out while the log said the server was up.
260
+ *
261
+ * Yielding every few hundred entries costs a few milliseconds over the whole
262
+ * walk and means a request waits for one directory rather than for the disk.
263
+ */
264
+ export async function findAudioAsync(root: string, every = 200): Promise<string[]> {
265
+ const out: string[] = [];
266
+ let stats;
267
+ try {
268
+ stats = statSync(root);
269
+ } catch {
270
+ return out;
271
+ }
272
+ if (stats.isFile()) return isAudio(root) ? [root] : out;
273
+
274
+ let since = 0;
275
+ const breathe = async (): Promise<void> => {
276
+ if (++since < every) return;
277
+ since = 0;
278
+ await new Promise((done) => setImmediate(done));
279
+ };
280
+
281
+ const walk = async (dir: string): Promise<void> => {
282
+ let entries: string[];
283
+ try {
284
+ entries = readdirSync(dir).sort();
285
+ } catch {
286
+ return;
287
+ }
288
+ for (const entry of entries) {
289
+ if (entry.startsWith(".")) continue;
290
+ const full = join(dir, entry);
291
+ await breathe();
292
+ let stat;
293
+ try {
294
+ stat = statSync(full);
295
+ } catch {
296
+ continue;
297
+ }
298
+ if (stat.isDirectory()) await walk(full);
299
+ else if (isAudio(full)) out.push(full);
300
+ }
301
+ };
302
+ await walk(root);
303
+ return out;
304
+ }
305
+
237
306
  export function displayName(track: Track): string {
238
307
  return track.artist ? `${track.artist} — ${track.title}` : track.title;
239
308
  }
package/src/server.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  SignIn,
44
44
  } from "./oauth.ts";
45
45
  import { needsAdmin, Owner } from "./owner.ts";
46
+ import { stateDir } from "./daemon.ts";
46
47
  import { readSession } from "./session.ts";
47
48
  import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
48
49
  import { PartyLine, telnyxSms } from "./partyline.ts";
@@ -60,13 +61,14 @@ import {
60
61
  paywallFromEnv,
61
62
  } from "./paywall.ts";
62
63
  import { isRemote, playsInBrowser, sourceLabel } from "./sources.ts";
63
- import { codecsOf, videoArgs } from "./audio.ts";
64
+ import { codecsOf, probeAsync, videoArgs } from "./audio.ts";
64
65
  import {
65
66
  allowedForListening,
66
67
  elevate,
67
68
  firewallInUse,
68
69
  certifiable,
69
70
  keyCookie,
71
+ rememberedKeys,
70
72
  keyFrom,
71
73
  keysMatch,
72
74
  lookupPublicIp,
@@ -107,6 +109,8 @@ export interface ServeOptions {
107
109
  * port, which is what the public deployment wants and no private one does.
108
110
  */
109
111
  key: boolean;
112
+ /** Mint a new share key rather than reusing the one this port had. */
113
+ newKey: boolean;
110
114
  /**
111
115
  * Ask the local firewall to let the port through, and put it back on the way
112
116
  * out. Off by default because it changes the machine, not just this process.
@@ -199,6 +203,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
199
203
  web: null,
200
204
  media: true,
201
205
  key: true,
206
+ newKey: false,
202
207
  openPort: false,
203
208
  announce: false,
204
209
  directory: false,
@@ -271,6 +276,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
271
276
  options.name = value();
272
277
  } else if (arg === "--owner") {
273
278
  options.owner = value();
279
+ } else if (arg === "--new-key") {
280
+ options.newKey = true;
274
281
  } else if (arg === "--ingest") {
275
282
  options.ingest = true;
276
283
  } else if (arg === "--rtmp-streams") {
@@ -2225,6 +2232,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2225
2232
  // Where to point OBS. Printed at startup since RTMP was added, which
2226
2233
  // is no use at all to somebody looking at the admin panel a day later.
2227
2234
  publish: options.publishUrls?.() ?? [],
2235
+ // And which of those slots somebody is already on, because the
2236
+ // question you have in front of three addresses is which one is free.
2237
+ channels: options.channels?.list().map(({ id, name, via }) => ({ id, name, via })) ?? [],
2228
2238
  });
2229
2239
  return;
2230
2240
  }
@@ -2815,10 +2825,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2815
2825
  const engine = new PlayerEngine([], root, tools);
2816
2826
 
2817
2827
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
2818
- const key = options.key ? newKey() : null;
2819
- // Minted whether or not it is published, so `nixamp admin` and the operator
2828
+ // The same keys this port used last time, so a link somebody was given
2829
+ // still works after a restart -- and a server is restarted to pick up a new
2830
+ // version, which is to say often. `--new-key` mints a fresh pair and forgets
2831
+ // the old one, which is the way to revoke a link that got out.
2832
+ const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
2833
+ const key = remembered?.key ?? null;
2834
+ // Kept whether or not it is published, so `nixamp admin` and the operator
2820
2835
  // both have a link they can hand out without handing over the controls.
2821
- const listenKey = key === null ? null : newKey();
2836
+ const listenKey = remembered?.listenKey ?? null;
2822
2837
  // Configuration can arrive from the directory later, so it is a box the
2823
2838
  // paywall reads rather than a value it was handed once.
2824
2839
  let paywallConfig: PaywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
@@ -3221,7 +3236,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3221
3236
  }
3222
3237
  console.log(`nixamp serve — ${found.length} tracks under ${root}`);
3223
3238
  if (isRemote(root)) return;
3224
- return loadTagged(tools, root)
3239
+ // Handed the files we already found. Tagging used to walk the whole
3240
+ // library a second time to discover the same paths, and that second walk
3241
+ // was the one that ran with the port already open.
3242
+ return loadTagged(tools, root, probeAsync, found.map((track) => track.path))
3225
3243
  .then((tagged) => engine.retag(tagged, root))
3226
3244
  .catch(() => {
3227
3245
  // Filenames are a working player. A failure here is worth nothing
package/src/share.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * every EventSource and every `<audio src>` on its own.
12
12
  */
13
13
  import { randomBytes, timingSafeEqual } from "node:crypto";
14
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname } from "node:path";
14
16
  import type { IncomingMessage } from "node:http";
15
17
  import { networkInterfaces } from "node:os";
16
18
 
@@ -37,6 +39,79 @@ export function newKey(): string {
37
39
  return randomBytes(16).toString("base64url");
38
40
  }
39
41
 
42
+ /** The pair of keys a server hands out: one that drives, one that only hears. */
43
+ export interface KeyPair {
44
+ key: string;
45
+ listenKey: string;
46
+ }
47
+
48
+ /**
49
+ * The keys this port used last time, or a new pair remembered for next time.
50
+ *
51
+ * Keys used to be minted on every start, so every link anybody had been given
52
+ * died the moment the server was restarted -- and a server gets restarted to
53
+ * pick up a new version, which is to say often. A link you cannot rely on is
54
+ * not a link you can share, which was most of why sharing did not feel like it
55
+ * worked.
56
+ *
57
+ * Kept per port, because two servers on one machine are two different
58
+ * audiences, and a single remembered key would hand each of them the other's.
59
+ *
60
+ * The trade is that a key which never changes is a key that stays valid if it
61
+ * leaks, so `fresh` mints a new pair and forgets the old one -- which is what
62
+ * `--new-key` is for.
63
+ */
64
+ export function rememberedKeys(
65
+ dir: string,
66
+ port: number,
67
+ fresh = false,
68
+ io: {
69
+ read: (path: string) => string | null;
70
+ write: (path: string, body: string) => void;
71
+ } = defaultKeyStore,
72
+ ): KeyPair {
73
+ const path = `${dir}/keys.json`;
74
+ let all: Record<string, KeyPair> = {};
75
+ const existing = io.read(path);
76
+ if (existing !== null) {
77
+ try {
78
+ const parsed = JSON.parse(existing) as Record<string, KeyPair>;
79
+ if (parsed && typeof parsed === "object") all = parsed;
80
+ } catch {
81
+ // A file we cannot read is a file we replace. Losing a key costs a link;
82
+ // refusing to start costs the whole server.
83
+ }
84
+ }
85
+
86
+ const held = all[String(port)];
87
+ if (!fresh && held && typeof held.key === "string" && typeof held.listenKey === "string") return held;
88
+
89
+ const minted: KeyPair = { key: newKey(), listenKey: newKey() };
90
+ all[String(port)] = minted;
91
+ try {
92
+ io.write(path, JSON.stringify(all, null, 2));
93
+ } catch {
94
+ // Unwritable state is a key that will not survive a restart, which is how
95
+ // it behaved before this existed. Not a reason to refuse to serve.
96
+ }
97
+ return minted;
98
+ }
99
+
100
+ const defaultKeyStore = {
101
+ read: (path: string): string | null => {
102
+ try {
103
+ return readFileSync(path, "utf8");
104
+ } catch {
105
+ return null;
106
+ }
107
+ },
108
+ write: (path: string, body: string): void => {
109
+ mkdirSync(dirname(path), { recursive: true });
110
+ // Readable only by its owner: it is the password to this server.
111
+ writeFileSync(path, body, { mode: 0o600 });
112
+ },
113
+ };
114
+
40
115
  /** Compare without leaking where two keys first differ. */
41
116
  export function keysMatch(a: string, b: string): boolean {
42
117
  const left = Buffer.from(a);
@@ -1 +1 @@
1
- import{t as e}from"./index-BL-Q9Q2e.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-DS7hEQqB.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 +1 @@
1
- :root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:nowrap;min-height:1.4em;overflow:hidden}.levelmeter{color:var(--accent);white-space:nowrap;margin-left:auto}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}
1
+ :root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:nowrap;min-height:1.4em;overflow:hidden}.levelmeter{color:var(--accent);white-space:nowrap;margin-left:auto}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list li.in-use .slot{color:var(--green)}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}