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/README.md +54 -0
- package/bin/nixamp.mjs +4 -1
- package/dist/admin.d.ts +47 -0
- package/dist/admin.js +209 -0
- package/dist/connections.d.ts +66 -0
- package/dist/connections.js +115 -0
- package/dist/daemon.d.ts +39 -0
- package/dist/daemon.js +170 -0
- package/dist/main.js +82 -6
- package/dist/manage.js +28 -6
- package/dist/playlist.d.ts +16 -0
- package/dist/playlist.js +57 -2
- package/dist/server.d.ts +38 -4
- package/dist/server.js +323 -23
- package/dist/share.d.ts +58 -0
- package/dist/share.js +153 -0
- package/dist/sources.d.ts +37 -0
- package/dist/sources.js +125 -0
- package/package.json +1 -1
- package/src/admin.ts +243 -0
- package/src/connections.ts +145 -0
- package/src/daemon.ts +193 -0
- package/src/main.ts +86 -6
- package/src/manage.ts +33 -6
- package/src/playlist.ts +68 -2
- package/src/server.ts +393 -21
- package/src/share.ts +166 -0
- package/src/sources.ts +136 -0
- package/web/dist/install.ps1 +214 -0
- package/web/dist/sw.js +1 -1
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,
|
|
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 [
|
|
45
|
-
nixamp serve [
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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}
|
|
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 =
|
|
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
|
}
|
package/dist/playlist.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
53
|
-
private
|
|
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>;
|