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/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,
|
|
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 [
|
|
68
|
-
nixamp serve [
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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}
|
|
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 =
|
|
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
|
|
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.
|