nixamp 0.1.0 → 0.2.0

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/daemon.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Daemon mode.
3
+ *
4
+ * `nixamp serve` holds a terminal. A server you leave running should not, and
5
+ * you should be able to walk away and come back to it, which means something on
6
+ * disk has to remember where it is and what key it minted. That file is the
7
+ * whole of the daemon: a pid to signal and enough to reconnect.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
14
+ export function stateDir() {
15
+ const base = process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
16
+ return join(base, "nixamp");
17
+ }
18
+ export function statePath() {
19
+ return join(stateDir(), "daemon.json");
20
+ }
21
+ export function logPath() {
22
+ return join(stateDir(), "daemon.log");
23
+ }
24
+ export function readState() {
25
+ try {
26
+ return JSON.parse(readFileSync(statePath(), "utf8"));
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ export function writeState(state) {
33
+ mkdirSync(dirname(statePath()), { recursive: true });
34
+ writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`);
35
+ }
36
+ export function clearState() {
37
+ rmSync(statePath(), { force: true });
38
+ }
39
+ /**
40
+ * Signal 0 asks "could I signal this?" without sending anything. A pid file
41
+ * outlives the process that wrote it often enough that trusting one is how you
42
+ * end up reporting a daemon that has been dead since Tuesday.
43
+ */
44
+ export function alive(pid) {
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ }
49
+ catch (error) {
50
+ // EPERM means it exists and belongs to someone else, which is still alive.
51
+ return error.code === "EPERM";
52
+ }
53
+ }
54
+ /** The daemon as far as anyone asking is concerned. */
55
+ export function status() {
56
+ const state = readState();
57
+ if (state === null)
58
+ return { running: false, state: null };
59
+ return { running: alive(state.pid), state };
60
+ }
61
+ /** The URL an admin client should talk to. */
62
+ export function daemonUrl(state) {
63
+ const host = state.host === "0.0.0.0" || state.host === "::" ? "127.0.0.1" : state.host;
64
+ return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
65
+ }
66
+ /**
67
+ * Start one, detached, and wait until it is actually answering before saying
68
+ * it started. Reporting success and leaving the user to discover a crash in a
69
+ * log file is the thing this is meant to avoid.
70
+ */
71
+ export async function start(argv, entry) {
72
+ const existing = status();
73
+ if (existing.running && existing.state) {
74
+ throw new Error(`nixamp: a daemon is already running (pid ${existing.state.pid}). Stop it first.`);
75
+ }
76
+ mkdirSync(stateDir(), { recursive: true });
77
+ const log = logPath();
78
+ const out = openSync(log, "a");
79
+ const child = spawn(process.execPath, [entry, "serve", ...argv, "--announce"], {
80
+ detached: true,
81
+ stdio: ["ignore", out, out],
82
+ env: { ...process.env, NIXAMP_DAEMON: "1" },
83
+ });
84
+ child.unref();
85
+ if (child.pid === undefined)
86
+ throw new Error("nixamp: could not start the daemon");
87
+ // The server prints one JSON line when it is listening, because guessing how
88
+ // long a start takes is how a flaky `daemon start` is written.
89
+ const announced = await waitForAnnounce(log, 15_000);
90
+ if (announced === null) {
91
+ try {
92
+ process.kill(child.pid, "SIGTERM");
93
+ }
94
+ catch {
95
+ // Already gone, which is the more likely reason we are here.
96
+ }
97
+ throw new Error(`nixamp: the daemon did not start. See ${log}`);
98
+ }
99
+ const state = { ...announced, pid: child.pid, startedAt: Date.now(), log };
100
+ writeState(state);
101
+ return state;
102
+ }
103
+ /** Poll the log for the announce line. */
104
+ async function waitForAnnounce(log, timeoutMs) {
105
+ const deadline = Date.now() + timeoutMs;
106
+ const from = existsSync(log) ? readFileSync(log, "utf8").length : 0;
107
+ while (Date.now() < deadline) {
108
+ await new Promise((done) => setTimeout(done, 100));
109
+ let text;
110
+ try {
111
+ text = readFileSync(log, "utf8").slice(from);
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ for (const line of text.split("\n")) {
117
+ if (!line.startsWith("{"))
118
+ continue;
119
+ try {
120
+ const parsed = JSON.parse(line);
121
+ if (parsed["nixamp"] === "listening") {
122
+ return {
123
+ host: String(parsed["host"]),
124
+ port: Number(parsed["port"]),
125
+ key: parsed["key"] ?? null,
126
+ source: String(parsed["source"]),
127
+ };
128
+ }
129
+ }
130
+ catch {
131
+ // A partial line: it will be complete on the next pass.
132
+ }
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ /** Stop it, and wait for it to actually be gone. */
138
+ export async function stop(timeoutMs = 5000) {
139
+ const { running, state } = status();
140
+ if (!state)
141
+ return false;
142
+ if (!running) {
143
+ clearState();
144
+ return false;
145
+ }
146
+ try {
147
+ process.kill(state.pid, "SIGTERM");
148
+ }
149
+ catch {
150
+ clearState();
151
+ return false;
152
+ }
153
+ const deadline = Date.now() + timeoutMs;
154
+ while (Date.now() < deadline) {
155
+ if (!alive(state.pid)) {
156
+ clearState();
157
+ return true;
158
+ }
159
+ await new Promise((done) => setTimeout(done, 100));
160
+ }
161
+ // It ignored SIGTERM. ffmpeg children make that more likely than it sounds.
162
+ try {
163
+ process.kill(state.pid, "SIGKILL");
164
+ }
165
+ catch {
166
+ // Gone between the check and the signal.
167
+ }
168
+ clearState();
169
+ return true;
170
+ }
package/dist/main.js CHANGED
@@ -7,11 +7,13 @@
7
7
  * ffmpeg decodes; we read every sample on its way to the speakers and draw it.
8
8
  */
9
9
  import { createApp, themes } from "@profullstack/hqtui";
10
+ import { fileURLToPath } from "node:url";
10
11
  import { resolve } from "node:path";
11
12
  import { detectTools, formatTime, peaks, RATE, Stream, toMono, } from "./audio.js";
12
13
  import { Analyser, bandEdges, bands, decay } from "./fft.js";
13
14
  import { version } from "./meta.js";
14
- import { displayName, loadPlaylist } from "./playlist.js";
15
+ import { displayName, loadSource } from "./playlist.js";
16
+ import { isRemote } from "./sources.js";
15
17
  import { DEFAULT_PORT } from "./server.js";
16
18
  const FFT_SIZE = 2048;
17
19
  export const BAND_COUNT = 24;
@@ -41,20 +43,82 @@ export function barGlyph(value) {
41
43
  }
42
44
  const HELP = `nixamp — it really whips the terminal's ass.
43
45
 
44
- nixamp [path] play a directory or a file in the terminal
45
- nixamp serve [path] [options] play here, and hand out a browser remote
46
+ nixamp [source] play it in the terminal
47
+ nixamp serve [source] [options] play here, and hand out a browser remote
48
+ nixamp daemon start|stop|status serve in the background, and let go of it
49
+ nixamp admin [--url U] [--key K] who is connected, and re-stream to them
46
50
  nixamp update [version] re-run the installer, keeping your choices
47
51
  nixamp uninstall [--yes] remove everything the installer created
48
52
 
53
+ A source is a directory, a file, an .m3u, an .m3u8, a .pls, or a URL to any
54
+ of those.
55
+
49
56
  Options for serve:
50
57
  -p, --port N port to listen on (default ${DEFAULT_PORT})
51
- -h, --host HOST address to bind (default 127.0.0.1; 0.0.0.0 for the LAN)
58
+ -h, --host HOST address to bind (default 0.0.0.0, every interface)
52
59
  --web DIR directory of built PWA files to serve at /
53
60
  --no-media do not stream the library's bytes to remotes
61
+ --no-key serve to anyone who can reach the port, with no share link
62
+ --open-port let the port through the local firewall, and close it on exit
54
63
 
55
64
  -v, --version print the version
56
65
  --help print this
57
66
  `;
67
+ /**
68
+ * `nixamp daemon <start|stop|status>`.
69
+ *
70
+ * The daemon is `nixamp serve` with nobody holding its terminal, so this is
71
+ * mostly bookkeeping: start it detached, remember where it went, and be able
72
+ * to answer whether it is still there.
73
+ */
74
+ async function runDaemon(argv) {
75
+ const d = await import("./daemon.js");
76
+ const [action = "status", ...rest] = argv;
77
+ const entry = fileURLToPath(new URL("./main.js", import.meta.url));
78
+ if (action === "start") {
79
+ try {
80
+ const state = await d.start(rest, entry);
81
+ console.log(`nixamp daemon running (pid ${state.pid})`);
82
+ const url = d.daemonUrl(state);
83
+ console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
84
+ console.log(` ${state.source}`);
85
+ console.log("");
86
+ console.log(" nixamp admin who is connected");
87
+ console.log(" nixamp daemon stop when you are done");
88
+ return 0;
89
+ }
90
+ catch (error) {
91
+ console.error(error.message);
92
+ return 1;
93
+ }
94
+ }
95
+ if (action === "stop") {
96
+ const stopped = await d.stop();
97
+ console.log(stopped ? "nixamp daemon stopped" : "nixamp: no daemon was running");
98
+ return 0;
99
+ }
100
+ if (action === "status") {
101
+ const { running, state } = d.status();
102
+ if (!state) {
103
+ console.log("nixamp: no daemon. Start one with `nixamp daemon start`.");
104
+ return 1;
105
+ }
106
+ // A pid file outlives its process often enough that saying "running"
107
+ // without checking is how you report a daemon that died on Tuesday.
108
+ if (!running) {
109
+ console.log(`nixamp: the daemon (pid ${state.pid}) is gone. See ${state.log}`);
110
+ return 1;
111
+ }
112
+ const url = d.daemonUrl(state);
113
+ console.log(`nixamp daemon running (pid ${state.pid})`);
114
+ console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
115
+ console.log(` ${state.source}`);
116
+ console.log(` up ${Math.round((Date.now() - state.startedAt) / 1000)}s`);
117
+ return 0;
118
+ }
119
+ console.error(`nixamp daemon: unknown action ${action}. Try start, stop or status.`);
120
+ return 64;
121
+ }
58
122
  /**
59
123
  * The whole CLI, as a function. `bin/nixamp.mjs` imports and calls it: relying
60
124
  * on `import.meta.main` there would leave the installed binary doing nothing,
@@ -67,6 +131,15 @@ export async function main() {
67
131
  await serve(rest, version());
68
132
  return;
69
133
  }
134
+ if (first === "daemon") {
135
+ process.exitCode = await runDaemon(rest);
136
+ return;
137
+ }
138
+ if (first === "admin") {
139
+ const { admin } = await import("./admin.js");
140
+ await admin(rest);
141
+ return;
142
+ }
70
143
  if (first === "update" || first === "uninstall") {
71
144
  const manage = await import("./manage.js");
72
145
  process.exitCode = first === "update" ? manage.update(rest) : manage.uninstall(rest);
@@ -80,9 +153,12 @@ export async function main() {
80
153
  console.log(HELP);
81
154
  return;
82
155
  }
83
- const target = resolve(first ?? ".");
156
+ // resolve() would turn https://host/x into /cwd/https:/host/x, so a URL is
157
+ // left exactly as it was typed.
158
+ const asked = first ?? ".";
159
+ const target = isRemote(asked) ? asked : resolve(asked);
84
160
  const tools = detectTools();
85
- const tracks = loadPlaylist(tools, target);
161
+ const tracks = await loadSource(tools, target);
86
162
  if (tracks.length === 0) {
87
163
  console.error(`nixamp: no audio files under ${target}`);
88
164
  process.exit(1);
package/dist/manage.js CHANGED
@@ -10,6 +10,8 @@ import { spawnSync } from "node:child_process";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  const SITE = "https://nixamp.com";
13
+ /** Windows has its own installer, its own shim and its own removal script. */
14
+ const windows = process.platform === "win32";
13
15
  /**
14
16
  * Where the installer put things. `NIXAMP_HOME` is exported by the shim it
15
17
  * wrote, which is the only thing that knows for certain; the walk up from this
@@ -53,7 +55,9 @@ function notInstalled(what) {
53
55
  console.error("");
54
56
  console.error(" Installed with npm or bun: npm uninstall -g nixamp");
55
57
  console.error(" Running from a checkout: delete the checkout");
56
- console.error(` Wanted the installed one: curl -fsSL ${SITE}/install.sh | sh`);
58
+ console.error(windows
59
+ ? ` Wanted the installed one: irm ${SITE}/install.ps1 | iex`
60
+ : ` Wanted the installed one: curl -fsSL ${SITE}/install.sh | sh`);
57
61
  return 69;
58
62
  }
59
63
  /**
@@ -66,12 +70,24 @@ export function update(argv) {
66
70
  const manifest = root ? readManifest(root) : null;
67
71
  if (!root || !manifest)
68
72
  return notInstalled("update");
69
- const installer = manifest.installer || `${SITE}/install.sh`;
70
- const args = ["-s", "--", manifest.desktop ? "--desktop" : "--cli-only", "--prefix", manifest.prefix];
73
+ const installer = manifest.installer || `${SITE}/${windows ? "install.ps1" : "install.sh"}`;
71
74
  const wanted = argv.find((a) => !a.startsWith("-"));
75
+ console.log(`nixamp ${manifest.version} is installed. Fetching the installer...`);
76
+ if (windows) {
77
+ // PowerShell fetches and runs it in one expression, which is also the
78
+ // documented install line, so an update takes a fresh install's path.
79
+ const flags = [manifest.desktop ? "" : "-CliOnly", "-Prefix", quote(manifest.prefix)];
80
+ if (wanted)
81
+ flags.push("-Version", quote(wanted));
82
+ const expression = `& ([scriptblock]::Create((irm ${installer}))) ${flags.filter(Boolean).join(" ")}`;
83
+ const run = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", expression], {
84
+ stdio: "inherit",
85
+ });
86
+ return run.status ?? 1;
87
+ }
88
+ const args = ["-s", "--", manifest.desktop ? "--desktop" : "--cli-only", "--prefix", manifest.prefix];
72
89
  if (wanted)
73
90
  args.push("--version", wanted);
74
- console.log(`nixamp ${manifest.version} is installed. Fetching the installer...`);
75
91
  const fetcher = which("curl") ? ["curl", "-fsSL", installer] : which("wget") ? ["wget", "-qO-", installer] : null;
76
92
  if (!fetcher) {
77
93
  console.error("nixamp: curl or wget is required to update.");
@@ -103,15 +119,21 @@ export function uninstall(argv) {
103
119
  console.log("Your music is not touched. Run `nixamp uninstall --yes` to go ahead.");
104
120
  return 0;
105
121
  }
106
- const script = join(root, "uninstall.sh");
122
+ const script = join(root, windows ? "uninstall.ps1" : "uninstall.sh");
107
123
  if (!existsSync(script)) {
108
124
  console.error(`nixamp: ${script} is missing, so removal cannot be exact.`);
109
125
  console.error(` The manifest lists: ${manifest.paths.join(", ")}`);
110
126
  return 1;
111
127
  }
112
- const run = spawnSync("sh", [script], { stdio: "inherit" });
128
+ const run = windows
129
+ ? spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script], { stdio: "inherit" })
130
+ : spawnSync("sh", [script], { stdio: "inherit" });
113
131
  return run.status ?? 1;
114
132
  }
133
+ /** A PowerShell single-quoted string: the only escape inside one is a doubled quote. */
134
+ function quote(value) {
135
+ return `'${value.replace(/'/g, "''")}'`;
136
+ }
115
137
  function which(command) {
116
138
  return spawnSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" }).status === 0;
117
139
  }
@@ -1,8 +1,24 @@
1
1
  import { type Tools, type Track } from "./audio.ts";
2
+ import { type Entry } from "./sources.ts";
2
3
  export declare const AUDIO_EXTENSIONS: Set<string>;
3
4
  export declare function isAudio(path: string): boolean;
4
5
  /** Every audio file under `root`, depth first. A single file is a playlist of one. */
5
6
  export declare function findAudio(root: string): string[];
7
+ /**
8
+ * Read a playlist, from disk or over the network. An HLS playlist is not a list
9
+ * of tracks but one stream in segments, so it comes back as a single entry and
10
+ * ffmpeg is left to do what it is good at.
11
+ */
12
+ export declare function readPlaylist(source: string): Promise<Entry[]>;
13
+ /**
14
+ * Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
15
+ * or a URL to any of those.
16
+ *
17
+ * Reading tags means an ffprobe per file, which is slow for a large library and
18
+ * slower still over the network, so it is only done for local files the caller
19
+ * asked about.
20
+ */
21
+ export declare function loadSource(tools: Tools, source: string, probeTags?: boolean): Promise<Track[]>;
6
22
  /**
7
23
  * Reading tags means an ffprobe per file, which is slow for a large library, so
8
24
  * the caller decides when to pay for it. Untagged entries still play.
package/dist/playlist.js CHANGED
@@ -1,7 +1,8 @@
1
- /** The playlist: audio files found on disk, in a stable order. */
2
- import { readdirSync, statSync } from "node:fs";
1
+ /** The playlist: audio found on disk or named by a playlist, in a stable order. */
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { probe } from "./audio.js";
5
+ import { isHls, isPlaylistFile, isRemote, nameOf, parseM3u, parsePls, } from "./sources.js";
5
6
  export const AUDIO_EXTENSIONS = new Set([
6
7
  ".mp3", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac",
7
8
  ".wav", ".wma", ".aiff", ".aif", ".alac", ".mp4", ".webm",
@@ -50,6 +51,60 @@ export function findAudio(root) {
50
51
  walk(root);
51
52
  return out;
52
53
  }
54
+ /** An entry that was never probed: playable, just not described. */
55
+ function bare(entry) {
56
+ return { path: entry.source, title: entry.title, artist: "", album: "", duration: entry.duration };
57
+ }
58
+ /**
59
+ * Read a playlist, from disk or over the network. An HLS playlist is not a list
60
+ * of tracks but one stream in segments, so it comes back as a single entry and
61
+ * ffmpeg is left to do what it is good at.
62
+ */
63
+ export async function readPlaylist(source) {
64
+ let text;
65
+ if (isRemote(source)) {
66
+ const response = await fetch(source, { redirect: "follow" });
67
+ if (!response.ok)
68
+ throw new Error(`nixamp: ${source} answered ${response.status}`);
69
+ text = await response.text();
70
+ }
71
+ else {
72
+ text = readFileSync(source, "utf8");
73
+ }
74
+ if (isHls(text))
75
+ return [{ source, title: nameOf(source), duration: 0 }];
76
+ const entries = /\.pls$/i.test(source) ? parsePls(text, source) : parseM3u(text, source);
77
+ return entries;
78
+ }
79
+ /**
80
+ * Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
81
+ * or a URL to any of those.
82
+ *
83
+ * Reading tags means an ffprobe per file, which is slow for a large library and
84
+ * slower still over the network, so it is only done for local files the caller
85
+ * asked about.
86
+ */
87
+ export async function loadSource(tools, source, probeTags = true) {
88
+ if (isPlaylistFile(source)) {
89
+ let entries;
90
+ try {
91
+ entries = await readPlaylist(source);
92
+ }
93
+ catch (error) {
94
+ // The playlist is the whole argument, so failing to read it is fatal --
95
+ // but it is fatal with a sentence, not a stack.
96
+ throw new Error(`nixamp: could not read ${source}: ${error.message.replace(/^nixamp: /, "")}`);
97
+ }
98
+ return entries.map((entry) => probeTags && !isRemote(entry.source) && entry.duration === 0
99
+ ? { ...probe(tools, entry.source), title: entry.title || probe(tools, entry.source).title }
100
+ : bare(entry));
101
+ }
102
+ // A bare URL is one remote thing to play. Whether it is a song or a live
103
+ // stream is ffmpeg's problem, and it is good at it.
104
+ if (isRemote(source))
105
+ return [bare({ source, title: nameOf(source), duration: 0 })];
106
+ return loadPlaylist(tools, source, probeTags);
107
+ }
53
108
  /**
54
109
  * Reading tags means an ffprobe per file, which is slow for a large library, so
55
110
  * the caller decides when to pay for it. Untagged entries still play.
package/dist/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+ import { Connections } from "./connections.ts";
2
3
  import { type Tools, type Track } from "./audio.ts";
3
4
  import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
4
5
  export declare const SERVE_BAND_COUNT = 24;
@@ -11,6 +12,22 @@ export interface ServeOptions {
11
12
  web: string | null;
12
13
  /** Stream the library's bytes to remotes. Off keeps the audio on this box. */
13
14
  media: boolean;
15
+ /**
16
+ * Require the key from the share link. Off serves to anyone who can reach the
17
+ * port, which is what the public deployment wants and no private one does.
18
+ */
19
+ key: boolean;
20
+ /**
21
+ * Ask the local firewall to let the port through, and put it back on the way
22
+ * out. Off by default because it changes the machine, not just this process.
23
+ */
24
+ openPort: boolean;
25
+ /**
26
+ * Print one JSON line once listening. `nixamp daemon start` reads it rather
27
+ * than sleeping and hoping, so a daemon that failed to bind is reported as
28
+ * failed instead of started.
29
+ */
30
+ announce: boolean;
14
31
  }
15
32
  /**
16
33
  * Flags are parsed by hand: three of them do not justify a dependency, and the
@@ -40,6 +57,12 @@ export interface Engine {
40
57
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
41
58
  /** Absolute path of a track, or undefined when the index is not one. */
42
59
  trackPath(index: number): string | undefined;
60
+ /**
61
+ * Play something else instead. Re-streaming is the whole reason the admin
62
+ * view exists: point a running server at a URL without restarting it and
63
+ * dropping every listener.
64
+ */
65
+ replace(tracks: Track[], root: string): void;
43
66
  stop(): void;
44
67
  }
45
68
  export declare function toRemoteTracks(tracks: Track[]): RemoteTrack[];
@@ -49,8 +72,8 @@ export declare function toRemoteTracks(tracks: Track[]): RemoteTrack[];
49
72
  * its way past so a remote can draw the same spectrum the TUI would.
50
73
  */
51
74
  export declare class PlayerEngine implements Engine {
52
- private readonly tracks;
53
- private readonly root;
75
+ private tracks;
76
+ private root;
54
77
  /** Frames a second pushed to remotes. */
55
78
  private readonly fps;
56
79
  private readonly listeners;
@@ -78,6 +101,7 @@ export declare class PlayerEngine implements Engine {
78
101
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
79
102
  private push;
80
103
  stop(): void;
104
+ replace(tracks: Track[], root: string): void;
81
105
  }
82
106
  /** An engine with no library behind it, for the hosted PWA. */
83
107
  export declare class EmptyEngine implements Engine {
@@ -87,12 +111,24 @@ export declare class EmptyEngine implements Engine {
87
111
  command(): void;
88
112
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
89
113
  trackPath(): undefined;
114
+ replace(): void;
90
115
  stop(): void;
91
116
  }
92
117
  export interface HandlerOptions {
93
118
  web: string | null;
94
119
  media: boolean;
95
120
  version: string;
121
+ /** The key from the share link, or null to serve to anyone who can connect. */
122
+ key?: string | null;
123
+ /** How to run ffmpeg, for the sources a browser cannot play by itself. */
124
+ ffmpeg?: string[];
125
+ /** Who is listening, for the admin view. */
126
+ connections?: Connections;
127
+ /**
128
+ * How to turn a source into tracks, for re-streaming. Injected rather than
129
+ * imported so the handler stays a plain function of a request.
130
+ */
131
+ load: (source: string) => Promise<Track[]>;
96
132
  }
97
133
  /**
98
134
  * The whole HTTP surface, as a plain function of a request — so a test can
@@ -100,6 +136,4 @@ export interface HandlerOptions {
100
136
  */
101
137
  export declare function createHandler(engine: Engine, options: HandlerOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
102
138
  export declare function createServer(engine: Engine, options: HandlerOptions): Server;
103
- /** Where a remote on another device should point its browser. */
104
- export declare function addressesFor(host: string, port: number): string[];
105
139
  export declare function serve(argv: string[], version?: string): Promise<void>;