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