nixamp 0.5.1 → 0.5.3
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 +21 -0
- package/dist/daemon.d.ts +25 -0
- package/dist/daemon.js +34 -0
- package/dist/main.js +25 -8
- package/dist/server.d.ts +21 -0
- package/dist/server.js +71 -9
- package/dist/share.d.ts +1 -1
- package/dist/share.js +8 -2
- package/package.json +1 -1
- package/src/daemon.ts +62 -0
- package/src/main.ts +23 -8
- package/src/server.ts +93 -9
- package/src/share.ts +13 -2
- package/web/dist/sw.js +1 -1
package/README.md
CHANGED
|
@@ -268,6 +268,27 @@ Start writes down where it went and the key it minted, waits until the server
|
|
|
268
268
|
is actually answering before saying it started, and prints the share link. It is
|
|
269
269
|
one daemon per user, and the state lives in `$XDG_STATE_HOME/nixamp`.
|
|
270
270
|
|
|
271
|
+
### Somewhere the rest of the world can reach
|
|
272
|
+
|
|
273
|
+
The addresses nixamp prints are the ones its own interfaces have, so a machine
|
|
274
|
+
behind NAT only ever sees `192.168.x` -- no use to anybody else, and nothing it
|
|
275
|
+
can publish. Tell it the address it answers on from outside:
|
|
276
|
+
|
|
277
|
+
```
|
|
278
|
+
nixamp serve ~/Music --public-url https://nixamp.example.com # or NIXAMP_PUBLIC_URL
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
That address is what the share links print and what the directory listing
|
|
282
|
+
carries. Getting one is your business, not nixamp's: a forwarded port, a reverse
|
|
283
|
+
proxy, or a tunnel, e.g.
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
cloudflared tunnel --url http://localhost:8420
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Without it, `--publish` is skipped entirely rather than listing a stream nobody
|
|
290
|
+
outside the house can open.
|
|
291
|
+
|
|
271
292
|
### Detaching, and coming back
|
|
272
293
|
|
|
273
294
|
`d` in the player hands the music to a daemon and gives you your terminal back.
|
package/dist/daemon.d.ts
CHANGED
|
@@ -8,6 +8,20 @@ export interface DaemonState {
|
|
|
8
8
|
startedAt: number;
|
|
9
9
|
/** Where its output went, for when it died and you want to know why. */
|
|
10
10
|
log: string;
|
|
11
|
+
/**
|
|
12
|
+
* The labelled addresses the server itself worked out, share key not applied.
|
|
13
|
+
*
|
|
14
|
+
* Recorded rather than recomputed because only the server knows them: a
|
|
15
|
+
* daemon bound to every interface has no single host to print, and the
|
|
16
|
+
* public one may be a tunnel it was told about rather than an interface
|
|
17
|
+
* anybody here can see. Absent on a state file written by an older nixamp.
|
|
18
|
+
*/
|
|
19
|
+
urls?: {
|
|
20
|
+
label: string;
|
|
21
|
+
url: string;
|
|
22
|
+
}[];
|
|
23
|
+
/** The firewall standing between this port and the rest of the network. */
|
|
24
|
+
firewall?: string | null;
|
|
11
25
|
}
|
|
12
26
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
13
27
|
export declare function stateDir(): string;
|
|
@@ -29,6 +43,17 @@ export declare function status(): {
|
|
|
29
43
|
};
|
|
30
44
|
/** The URL an admin client should talk to. */
|
|
31
45
|
export declare function daemonUrl(state: DaemonState): string;
|
|
46
|
+
/**
|
|
47
|
+
* What `nixamp daemon start` prints, as lines, so it can be tested without
|
|
48
|
+
* starting a daemon.
|
|
49
|
+
*
|
|
50
|
+
* Every address the server found, not one loopback link: the point of a daemon
|
|
51
|
+
* is the phone in the other room, and 127.0.0.1 is the single address that
|
|
52
|
+
* cannot be handed to anybody. The firewall warning comes with it because the
|
|
53
|
+
* server writes that into a log file nobody reads, not to the person who just
|
|
54
|
+
* typed the command.
|
|
55
|
+
*/
|
|
56
|
+
export declare function daemonLines(state: DaemonState): string[];
|
|
32
57
|
/**
|
|
33
58
|
* Start one, detached, and wait until it is actually answering before saying
|
|
34
59
|
* it started. Reporting success and leaving the user to discover a crash in a
|
package/dist/daemon.js
CHANGED
|
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
|
+
import { portCommands } from "./share.js";
|
|
13
14
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
14
15
|
export function stateDir() {
|
|
15
16
|
const base = process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
|
|
@@ -63,6 +64,36 @@ export function daemonUrl(state) {
|
|
|
63
64
|
const host = state.host === "0.0.0.0" || state.host === "::" ? "127.0.0.1" : state.host;
|
|
64
65
|
return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* What `nixamp daemon start` prints, as lines, so it can be tested without
|
|
69
|
+
* starting a daemon.
|
|
70
|
+
*
|
|
71
|
+
* Every address the server found, not one loopback link: the point of a daemon
|
|
72
|
+
* is the phone in the other room, and 127.0.0.1 is the single address that
|
|
73
|
+
* cannot be handed to anybody. The firewall warning comes with it because the
|
|
74
|
+
* server writes that into a log file nobody reads, not to the person who just
|
|
75
|
+
* typed the command.
|
|
76
|
+
*/
|
|
77
|
+
export function daemonLines(state) {
|
|
78
|
+
const link = (url) => (state.key ? `${url}/s/${state.key}` : url);
|
|
79
|
+
// A state file written by an older nixamp has no list, so host and port
|
|
80
|
+
// still stand in rather than printing nothing at all.
|
|
81
|
+
const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
|
|
82
|
+
const width = Math.max(...addresses.map((a) => a.label.length), "source".length);
|
|
83
|
+
const lines = [`nixamp daemon running (pid ${state.pid})`];
|
|
84
|
+
for (const { label, url } of addresses)
|
|
85
|
+
lines.push(` ${label.padEnd(width)} ${link(url)}`);
|
|
86
|
+
lines.push(` ${"source".padEnd(width)} ${state.source}`);
|
|
87
|
+
if (state.firewall) {
|
|
88
|
+
const { open } = portCommands(state.firewall, state.port);
|
|
89
|
+
lines.push("", ` ${state.firewall} is running, so nothing else can reach port ${state.port} yet:`, ` sudo ${open.join(" ")}`, " or `nixamp daemon stop` and start again with --open-port.");
|
|
90
|
+
}
|
|
91
|
+
if (!addresses.some((a) => a.label === "on the internet")) {
|
|
92
|
+
lines.push("", " None of those work from outside this network. If it should:", " nixamp daemon start ... --public-url https://your-tunnel.example.com");
|
|
93
|
+
}
|
|
94
|
+
lines.push("", " nixamp attach the player, in front of it", " nixamp admin who is connected", " nixamp daemon stop when you are done");
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
66
97
|
/**
|
|
67
98
|
* Start one, detached, and wait until it is actually answering before saying
|
|
68
99
|
* it started. Reporting success and leaving the user to discover a crash in a
|
|
@@ -119,11 +150,14 @@ async function waitForAnnounce(log, timeoutMs) {
|
|
|
119
150
|
try {
|
|
120
151
|
const parsed = JSON.parse(line);
|
|
121
152
|
if (parsed["nixamp"] === "listening") {
|
|
153
|
+
const urls = parsed["urls"];
|
|
122
154
|
return {
|
|
123
155
|
host: String(parsed["host"]),
|
|
124
156
|
port: Number(parsed["port"]),
|
|
125
157
|
key: parsed["key"] ?? null,
|
|
126
158
|
source: String(parsed["source"]),
|
|
159
|
+
...(Array.isArray(urls) ? { urls: urls } : {}),
|
|
160
|
+
...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
|
|
127
161
|
};
|
|
128
162
|
}
|
|
129
163
|
}
|
package/dist/main.js
CHANGED
|
@@ -67,6 +67,9 @@ Options for serve:
|
|
|
67
67
|
--publish list it at nixamp.com/directory without asking first
|
|
68
68
|
--no-publish never list it, and do not ask
|
|
69
69
|
--name NAME what to call it in the directory (default: this hostname)
|
|
70
|
+
--public-url URL the address this server is reachable at from outside,
|
|
71
|
+
when that is a tunnel or a forwarded port rather than one of
|
|
72
|
+
its own interfaces. Also NIXAMP_PUBLIC_URL
|
|
70
73
|
--ingest accept a live stream in at POST /api/ingest
|
|
71
74
|
--rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
|
|
72
75
|
--rtmp-streams N how many may publish at once (default 3, a port each)
|
|
@@ -192,13 +195,8 @@ async function runDaemon(argv) {
|
|
|
192
195
|
if (action === "start") {
|
|
193
196
|
try {
|
|
194
197
|
const state = await d.start(rest, entry);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
|
|
198
|
-
console.log(` ${state.source}`);
|
|
199
|
-
console.log("");
|
|
200
|
-
console.log(" nixamp admin who is connected");
|
|
201
|
-
console.log(" nixamp daemon stop when you are done");
|
|
198
|
+
for (const line of d.daemonLines(state))
|
|
199
|
+
console.log(line);
|
|
202
200
|
return 0;
|
|
203
201
|
}
|
|
204
202
|
catch (error) {
|
|
@@ -300,7 +298,10 @@ export async function main() {
|
|
|
300
298
|
const asked = first ?? ".";
|
|
301
299
|
const target = isRemote(asked) ? asked : resolve(asked);
|
|
302
300
|
const tools = detectTools();
|
|
303
|
-
|
|
301
|
+
// Names now, tags later: an ffprobe per file over a large library is minutes
|
|
302
|
+
// of a blank terminal before the player appears. The list is the same list;
|
|
303
|
+
// only the titles arrive late, and they arrive into a player already running.
|
|
304
|
+
const tracks = await loadSource(tools, target, false);
|
|
304
305
|
if (tracks.length === 0) {
|
|
305
306
|
console.error(`nixamp: no audio files under ${target}`);
|
|
306
307
|
process.exit(1);
|
|
@@ -311,6 +312,22 @@ export async function main() {
|
|
|
311
312
|
// gone. A field rather than a local, because a local assigned only inside a
|
|
312
313
|
// closure stays narrowed to null for the checker.
|
|
313
314
|
const handoff = { to: null };
|
|
315
|
+
// The titles, arriving into a player that is already up. Not awaited, and
|
|
316
|
+
// applied only if the list is still the one it describes.
|
|
317
|
+
if (!isRemote(target)) {
|
|
318
|
+
void loadSource(tools, target, true)
|
|
319
|
+
.then((tagged) => {
|
|
320
|
+
if (tagged.length !== state.tracks.length)
|
|
321
|
+
return;
|
|
322
|
+
if (tagged.some((track, at) => track.path !== state.tracks[at]?.path))
|
|
323
|
+
return;
|
|
324
|
+
state.tracks = tagged;
|
|
325
|
+
app.invalidate();
|
|
326
|
+
})
|
|
327
|
+
.catch(() => {
|
|
328
|
+
// Filenames play. Nothing to say about tags that would not read.
|
|
329
|
+
});
|
|
330
|
+
}
|
|
314
331
|
const analyser = new Analyser(FFT_SIZE, RATE);
|
|
315
332
|
const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
|
|
316
333
|
// Samples accumulate until there are enough for one transform.
|
package/dist/server.d.ts
CHANGED
|
@@ -47,6 +47,15 @@ export interface ServeOptions {
|
|
|
47
47
|
publish: "ask" | "yes" | "no";
|
|
48
48
|
/** What to call it in the list. Defaults to this machine's hostname. */
|
|
49
49
|
name: string;
|
|
50
|
+
/**
|
|
51
|
+
* The address this server is reachable at from outside, when that is not one
|
|
52
|
+
* of its own interfaces: a tunnel, a reverse proxy, a forwarded port.
|
|
53
|
+
*
|
|
54
|
+
* Without it a machine behind NAT has nothing to publish -- every address it
|
|
55
|
+
* can see is a 192.168 one that is no use to anybody else -- so the directory
|
|
56
|
+
* listing is skipped and the printed links only work inside the house.
|
|
57
|
+
*/
|
|
58
|
+
publicUrl: string;
|
|
50
59
|
/**
|
|
51
60
|
* Charge for listening once the stream is busy. Off unless asked for, and
|
|
52
61
|
* useless without somewhere to pay: see NIXAMP_PAY_TO.
|
|
@@ -107,6 +116,16 @@ export interface Engine {
|
|
|
107
116
|
* dropping every listener.
|
|
108
117
|
*/
|
|
109
118
|
replace(tracks: Track[], root: string): void;
|
|
119
|
+
/**
|
|
120
|
+
* The same tracks, now with their tags.
|
|
121
|
+
*
|
|
122
|
+
* Startup lists filenames and begins serving immediately, because an ffprobe
|
|
123
|
+
* per file over a real library takes minutes; the tags arrive afterwards and
|
|
124
|
+
* land here. Unlike `replace` this must not disturb anything -- whoever is
|
|
125
|
+
* listening keeps listening, and the only visible change is that the titles
|
|
126
|
+
* fill in.
|
|
127
|
+
*/
|
|
128
|
+
retag(tracks: Track[], root: string): void;
|
|
110
129
|
stop(): void;
|
|
111
130
|
}
|
|
112
131
|
export declare function toRemoteTracks(tracks: Track[]): RemoteTrack[];
|
|
@@ -146,6 +165,7 @@ export declare class PlayerEngine implements Engine {
|
|
|
146
165
|
private push;
|
|
147
166
|
stop(): void;
|
|
148
167
|
replace(tracks: Track[], root: string): void;
|
|
168
|
+
retag(tracks: Track[], root: string): void;
|
|
149
169
|
}
|
|
150
170
|
/** An engine with no library behind it, for the hosted PWA. */
|
|
151
171
|
export declare class EmptyEngine implements Engine {
|
|
@@ -156,6 +176,7 @@ export declare class EmptyEngine implements Engine {
|
|
|
156
176
|
subscribe(listener: (snapshot: Snapshot) => void): () => void;
|
|
157
177
|
trackPath(): undefined;
|
|
158
178
|
replace(): void;
|
|
179
|
+
retag(): void;
|
|
159
180
|
stop(): void;
|
|
160
181
|
}
|
|
161
182
|
/**
|
package/dist/server.js
CHANGED
|
@@ -66,6 +66,7 @@ export function parseServeArgs(argv) {
|
|
|
66
66
|
directory: false,
|
|
67
67
|
publish: "ask",
|
|
68
68
|
name: "",
|
|
69
|
+
publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
|
|
69
70
|
x402: false,
|
|
70
71
|
owner: "",
|
|
71
72
|
ingest: false,
|
|
@@ -117,6 +118,15 @@ export function parseServeArgs(argv) {
|
|
|
117
118
|
else if (arg === "--no-publish") {
|
|
118
119
|
options.publish = "no";
|
|
119
120
|
}
|
|
121
|
+
else if (arg === "--public-url") {
|
|
122
|
+
const given = value().trim();
|
|
123
|
+
// A hostname on its own is the likely typo, and it fails much later --
|
|
124
|
+
// as a directory listing nobody can open -- so it is refused here.
|
|
125
|
+
if (!/^https?:\/\/[^\s/]+/i.test(given)) {
|
|
126
|
+
throw new Error("nixamp serve: --public-url must be a URL, e.g. https://nixamp.example.com");
|
|
127
|
+
}
|
|
128
|
+
options.publicUrl = given.replace(/\/+$/, "");
|
|
129
|
+
}
|
|
120
130
|
else if (arg === "--name") {
|
|
121
131
|
options.name = value();
|
|
122
132
|
}
|
|
@@ -444,6 +454,19 @@ export class PlayerEngine {
|
|
|
444
454
|
this.state.note = "";
|
|
445
455
|
this.push();
|
|
446
456
|
}
|
|
457
|
+
retag(tracks, root) {
|
|
458
|
+
// Dropped rather than applied if the library moved underneath: somebody
|
|
459
|
+
// re-streamed while the tagging was still running, and these tags describe
|
|
460
|
+
// something nobody is playing any more.
|
|
461
|
+
if (root !== this.root || tracks.length !== this.tracks.length)
|
|
462
|
+
return;
|
|
463
|
+
if (tracks.some((track, at) => track.path !== this.tracks[at]?.path))
|
|
464
|
+
return;
|
|
465
|
+
this.tracks = tracks;
|
|
466
|
+
// No stop, no index reset: the only thing that changes is what the titles
|
|
467
|
+
// say, and every remote finds out because a snapshot goes out.
|
|
468
|
+
this.push();
|
|
469
|
+
}
|
|
447
470
|
}
|
|
448
471
|
/** An engine with no library behind it, for the hosted PWA. */
|
|
449
472
|
export class EmptyEngine {
|
|
@@ -463,6 +486,7 @@ export class EmptyEngine {
|
|
|
463
486
|
return undefined;
|
|
464
487
|
}
|
|
465
488
|
replace() { }
|
|
489
|
+
retag() { }
|
|
466
490
|
stop() { }
|
|
467
491
|
}
|
|
468
492
|
const CORS = {
|
|
@@ -1804,7 +1828,18 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
1804
1828
|
const options = parseServeArgs(argv);
|
|
1805
1829
|
const root = isRemote(options.root) ? options.root : resolve(options.root);
|
|
1806
1830
|
const tools = detectTools();
|
|
1807
|
-
|
|
1831
|
+
// Names now, tags later.
|
|
1832
|
+
//
|
|
1833
|
+
// Reading tags is an ffprobe per file, which over a real library is minutes,
|
|
1834
|
+
// and every one of them used to happen before this process printed a word or
|
|
1835
|
+
// listened on a port. `nixamp serve ~/music` looked hung, and `nixamp daemon
|
|
1836
|
+
// start` was worse: it waits fifteen seconds for the announce line, killed a
|
|
1837
|
+
// daemon that was working perfectly, and reported a failure whose log was
|
|
1838
|
+
// empty because nothing had been written to it yet.
|
|
1839
|
+
//
|
|
1840
|
+
// So the filenames are enough to start: the server is up and answering in the
|
|
1841
|
+
// time it takes to walk the directory, and the titles fill in behind it.
|
|
1842
|
+
const tracks = await loadSource(tools, root, false);
|
|
1808
1843
|
const engine = tracks.length > 0
|
|
1809
1844
|
? new PlayerEngine(tracks, root, tools)
|
|
1810
1845
|
: new EmptyEngine(`No audio files under ${root}.`);
|
|
@@ -2050,14 +2085,45 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2050
2085
|
return { status: done.status, stdout: done.stdout ?? "" };
|
|
2051
2086
|
},
|
|
2052
2087
|
};
|
|
2088
|
+
// The link, not the address. Without the key the address is a 401, so
|
|
2089
|
+
// printing a bare host:port would be printing something that does not work.
|
|
2090
|
+
const addresses = reachableAddresses(options.host, port, options.publicUrl);
|
|
2091
|
+
// Listening on every interface proves the socket is open here and nothing
|
|
2092
|
+
// about the path between here and the phone.
|
|
2093
|
+
const listening = options.host === "0.0.0.0" || options.host === "::";
|
|
2094
|
+
const firewall = listening ? firewallInUse(io) : null;
|
|
2053
2095
|
if (options.announce) {
|
|
2054
|
-
|
|
2096
|
+
// Everything `nixamp daemon start` needs to print what this would have
|
|
2097
|
+
// printed. Without the addresses it could only reconstruct host and port,
|
|
2098
|
+
// which for a server bound to every interface means it printed 127.0.0.1 --
|
|
2099
|
+
// an address that works on exactly the machine you are already sitting at.
|
|
2100
|
+
// The firewall matters for the same reason: the warning was going into a
|
|
2101
|
+
// log file nobody reads instead of to the person who just typed the
|
|
2102
|
+
// command.
|
|
2103
|
+
console.log(JSON.stringify({
|
|
2104
|
+
nixamp: "listening",
|
|
2105
|
+
host: options.host,
|
|
2106
|
+
port,
|
|
2107
|
+
key,
|
|
2108
|
+
source: root,
|
|
2109
|
+
urls: addresses,
|
|
2110
|
+
firewall,
|
|
2111
|
+
}));
|
|
2112
|
+
}
|
|
2113
|
+
// The other half of "names now, tags later". It runs while the banner is
|
|
2114
|
+
// printed and while the publish prompt waits, and it is deliberately not
|
|
2115
|
+
// awaited: nothing downstream needs it, and a library that takes a minute to
|
|
2116
|
+
// read should cost nobody a minute of silence.
|
|
2117
|
+
if (tracks.length > 0 && !isRemote(root)) {
|
|
2118
|
+
void loadSource(tools, root, true)
|
|
2119
|
+
.then((tagged) => engine.retag(tagged, root))
|
|
2120
|
+
.catch(() => {
|
|
2121
|
+
// Filenames are a working player. A failure here is worth nothing but
|
|
2122
|
+
// titles that stay as they are.
|
|
2123
|
+
});
|
|
2055
2124
|
}
|
|
2056
2125
|
console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
|
|
2057
2126
|
console.log("");
|
|
2058
|
-
// The link, not the address. Without the key the address is a 401, so
|
|
2059
|
-
// printing a bare host:port would be printing something that does not work.
|
|
2060
|
-
const addresses = reachableAddresses(options.host, port);
|
|
2061
2127
|
const width = Math.max(...addresses.map((a) => a.label.length));
|
|
2062
2128
|
for (const { label, url } of addresses) {
|
|
2063
2129
|
console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
|
|
@@ -2114,10 +2180,6 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2114
2180
|
}
|
|
2115
2181
|
if (web === null)
|
|
2116
2182
|
console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
|
|
2117
|
-
// Listening on every interface proves the socket is open here and nothing
|
|
2118
|
-
// about the path between here and the phone.
|
|
2119
|
-
const listening = options.host === "0.0.0.0" || options.host === "::";
|
|
2120
|
-
const firewall = listening ? firewallInUse(io) : null;
|
|
2121
2183
|
let closePort = null;
|
|
2122
2184
|
if (firewall !== null) {
|
|
2123
2185
|
const { open, close } = portCommands(firewall, port);
|
package/dist/share.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export declare function classify(address: string): "private" | "cgnat" | "public
|
|
|
31
31
|
* somewhere else can open. It is labelled for what it is, because the key in
|
|
32
32
|
* the link is then the only thing between a stranger and the library.
|
|
33
33
|
*/
|
|
34
|
-
export declare function reachableAddresses(host: string, port: number): {
|
|
34
|
+
export declare function reachableAddresses(host: string, port: number, publicUrl?: string): {
|
|
35
35
|
label: string;
|
|
36
36
|
url: string;
|
|
37
37
|
}[];
|
package/dist/share.js
CHANGED
|
@@ -80,14 +80,19 @@ export function classify(address) {
|
|
|
80
80
|
* somewhere else can open. It is labelled for what it is, because the key in
|
|
81
81
|
* the link is then the only thing between a stranger and the library.
|
|
82
82
|
*/
|
|
83
|
-
export function reachableAddresses(host, port) {
|
|
83
|
+
export function reachableAddresses(host, port, publicUrl = "") {
|
|
84
84
|
const link = (address) => {
|
|
85
85
|
// A bare IPv6 address needs brackets before it is a URL.
|
|
86
86
|
const authority = address.includes(":") ? `[${address}]` : address;
|
|
87
87
|
return `http://${authority}:${port}`;
|
|
88
88
|
};
|
|
89
|
+
// An address somebody told us about, because it is one this machine cannot
|
|
90
|
+
// know: a tunnel, a reverse proxy, or a router forwarding a port. It goes
|
|
91
|
+
// first so that it, and not a guess from an interface, is the address this
|
|
92
|
+
// stream is published under.
|
|
93
|
+
const told = publicUrl ? [{ label: "on the internet", url: publicUrl.replace(/\/+$/, "") }] : [];
|
|
89
94
|
if (host !== "0.0.0.0" && host !== "::")
|
|
90
|
-
return [{ label: "here", url: link(host) }];
|
|
95
|
+
return [...told, { label: "here", url: link(host) }];
|
|
91
96
|
const LABELS = { private: "on your network", cgnat: "on tailscale", public: "on the internet" };
|
|
92
97
|
const found = [];
|
|
93
98
|
for (const [name, entries] of Object.entries(networkInterfaces())) {
|
|
@@ -104,6 +109,7 @@ export function reachableAddresses(host, port) {
|
|
|
104
109
|
const order = { private: 0, cgnat: 1, public: 2 };
|
|
105
110
|
found.sort((x, y) => order[x.kind] - order[y.kind]);
|
|
106
111
|
return [
|
|
112
|
+
...told,
|
|
107
113
|
{ label: "here", url: `http://localhost:${port}` },
|
|
108
114
|
...found.map(({ label, url }) => ({ label, url })),
|
|
109
115
|
];
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
|
+
import { type Firewall, portCommands } from "./share.ts";
|
|
13
14
|
|
|
14
15
|
export interface DaemonState {
|
|
15
16
|
pid: number;
|
|
@@ -21,6 +22,17 @@ export interface DaemonState {
|
|
|
21
22
|
startedAt: number;
|
|
22
23
|
/** Where its output went, for when it died and you want to know why. */
|
|
23
24
|
log: string;
|
|
25
|
+
/**
|
|
26
|
+
* The labelled addresses the server itself worked out, share key not applied.
|
|
27
|
+
*
|
|
28
|
+
* Recorded rather than recomputed because only the server knows them: a
|
|
29
|
+
* daemon bound to every interface has no single host to print, and the
|
|
30
|
+
* public one may be a tunnel it was told about rather than an interface
|
|
31
|
+
* anybody here can see. Absent on a state file written by an older nixamp.
|
|
32
|
+
*/
|
|
33
|
+
urls?: { label: string; url: string }[];
|
|
34
|
+
/** The firewall standing between this port and the rest of the network. */
|
|
35
|
+
firewall?: string | null;
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
@@ -82,6 +94,53 @@ export function daemonUrl(state: DaemonState): string {
|
|
|
82
94
|
return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
/**
|
|
98
|
+
* What `nixamp daemon start` prints, as lines, so it can be tested without
|
|
99
|
+
* starting a daemon.
|
|
100
|
+
*
|
|
101
|
+
* Every address the server found, not one loopback link: the point of a daemon
|
|
102
|
+
* is the phone in the other room, and 127.0.0.1 is the single address that
|
|
103
|
+
* cannot be handed to anybody. The firewall warning comes with it because the
|
|
104
|
+
* server writes that into a log file nobody reads, not to the person who just
|
|
105
|
+
* typed the command.
|
|
106
|
+
*/
|
|
107
|
+
export function daemonLines(state: DaemonState): string[] {
|
|
108
|
+
const link = (url: string): string => (state.key ? `${url}/s/${state.key}` : url);
|
|
109
|
+
// A state file written by an older nixamp has no list, so host and port
|
|
110
|
+
// still stand in rather than printing nothing at all.
|
|
111
|
+
const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
|
|
112
|
+
const width = Math.max(...addresses.map((a) => a.label.length), "source".length);
|
|
113
|
+
|
|
114
|
+
const lines = [`nixamp daemon running (pid ${state.pid})`];
|
|
115
|
+
for (const { label, url } of addresses) lines.push(` ${label.padEnd(width)} ${link(url)}`);
|
|
116
|
+
lines.push(` ${"source".padEnd(width)} ${state.source}`);
|
|
117
|
+
|
|
118
|
+
if (state.firewall) {
|
|
119
|
+
const { open } = portCommands(state.firewall as Firewall, state.port);
|
|
120
|
+
lines.push(
|
|
121
|
+
"",
|
|
122
|
+
` ${state.firewall} is running, so nothing else can reach port ${state.port} yet:`,
|
|
123
|
+
` sudo ${open.join(" ")}`,
|
|
124
|
+
" or `nixamp daemon stop` and start again with --open-port.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (!addresses.some((a) => a.label === "on the internet")) {
|
|
128
|
+
lines.push(
|
|
129
|
+
"",
|
|
130
|
+
" None of those work from outside this network. If it should:",
|
|
131
|
+
" nixamp daemon start ... --public-url https://your-tunnel.example.com",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
lines.push(
|
|
136
|
+
"",
|
|
137
|
+
" nixamp attach the player, in front of it",
|
|
138
|
+
" nixamp admin who is connected",
|
|
139
|
+
" nixamp daemon stop when you are done",
|
|
140
|
+
);
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
|
|
85
144
|
/**
|
|
86
145
|
* Start one, detached, and wait until it is actually answering before saying
|
|
87
146
|
* it started. Reporting success and leaving the user to discover a crash in a
|
|
@@ -142,11 +201,14 @@ async function waitForAnnounce(
|
|
|
142
201
|
try {
|
|
143
202
|
const parsed = JSON.parse(line) as { nixamp?: string } & Record<string, unknown>;
|
|
144
203
|
if (parsed["nixamp"] === "listening") {
|
|
204
|
+
const urls = parsed["urls"];
|
|
145
205
|
return {
|
|
146
206
|
host: String(parsed["host"]),
|
|
147
207
|
port: Number(parsed["port"]),
|
|
148
208
|
key: (parsed["key"] as string | null) ?? null,
|
|
149
209
|
source: String(parsed["source"]),
|
|
210
|
+
...(Array.isArray(urls) ? { urls: urls as { label: string; url: string }[] } : {}),
|
|
211
|
+
...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
|
|
150
212
|
};
|
|
151
213
|
}
|
|
152
214
|
} catch {
|
package/src/main.ts
CHANGED
|
@@ -91,6 +91,9 @@ Options for serve:
|
|
|
91
91
|
--publish list it at nixamp.com/directory without asking first
|
|
92
92
|
--no-publish never list it, and do not ask
|
|
93
93
|
--name NAME what to call it in the directory (default: this hostname)
|
|
94
|
+
--public-url URL the address this server is reachable at from outside,
|
|
95
|
+
when that is a tunnel or a forwarded port rather than one of
|
|
96
|
+
its own interfaces. Also NIXAMP_PUBLIC_URL
|
|
94
97
|
--ingest accept a live stream in at POST /api/ingest
|
|
95
98
|
--rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
|
|
96
99
|
--rtmp-streams N how many may publish at once (default 3, a port each)
|
|
@@ -221,13 +224,7 @@ async function runDaemon(argv: string[]): Promise<number> {
|
|
|
221
224
|
if (action === "start") {
|
|
222
225
|
try {
|
|
223
226
|
const state = await d.start(rest, entry);
|
|
224
|
-
|
|
225
|
-
const url = d.daemonUrl(state);
|
|
226
|
-
console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
|
|
227
|
-
console.log(` ${state.source}`);
|
|
228
|
-
console.log("");
|
|
229
|
-
console.log(" nixamp admin who is connected");
|
|
230
|
-
console.log(" nixamp daemon stop when you are done");
|
|
227
|
+
for (const line of d.daemonLines(state)) console.log(line);
|
|
231
228
|
return 0;
|
|
232
229
|
} catch (error) {
|
|
233
230
|
console.error((error as Error).message);
|
|
@@ -334,7 +331,10 @@ export async function main(): Promise<void> {
|
|
|
334
331
|
const asked = first ?? ".";
|
|
335
332
|
const target = isRemote(asked) ? asked : resolve(asked);
|
|
336
333
|
const tools = detectTools();
|
|
337
|
-
|
|
334
|
+
// Names now, tags later: an ffprobe per file over a large library is minutes
|
|
335
|
+
// of a blank terminal before the player appears. The list is the same list;
|
|
336
|
+
// only the titles arrive late, and they arrive into a player already running.
|
|
337
|
+
const tracks = await loadSource(tools, target, false);
|
|
338
338
|
if (tracks.length === 0) {
|
|
339
339
|
console.error(`nixamp: no audio files under ${target}`);
|
|
340
340
|
process.exit(1);
|
|
@@ -347,6 +347,21 @@ export async function main(): Promise<void> {
|
|
|
347
347
|
// closure stays narrowed to null for the checker.
|
|
348
348
|
const handoff: { to: { daemon: DaemonState; url: string } | null } = { to: null };
|
|
349
349
|
|
|
350
|
+
// The titles, arriving into a player that is already up. Not awaited, and
|
|
351
|
+
// applied only if the list is still the one it describes.
|
|
352
|
+
if (!isRemote(target)) {
|
|
353
|
+
void loadSource(tools, target, true)
|
|
354
|
+
.then((tagged) => {
|
|
355
|
+
if (tagged.length !== state.tracks.length) return;
|
|
356
|
+
if (tagged.some((track, at) => track.path !== state.tracks[at]?.path)) return;
|
|
357
|
+
state.tracks = tagged;
|
|
358
|
+
app.invalidate();
|
|
359
|
+
})
|
|
360
|
+
.catch(() => {
|
|
361
|
+
// Filenames play. Nothing to say about tags that would not read.
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
350
365
|
const analyser = new Analyser(FFT_SIZE, RATE);
|
|
351
366
|
const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
|
|
352
367
|
// Samples accumulate until there are enough for one transform.
|
package/src/server.ts
CHANGED
|
@@ -119,6 +119,15 @@ export interface ServeOptions {
|
|
|
119
119
|
publish: "ask" | "yes" | "no";
|
|
120
120
|
/** What to call it in the list. Defaults to this machine's hostname. */
|
|
121
121
|
name: string;
|
|
122
|
+
/**
|
|
123
|
+
* The address this server is reachable at from outside, when that is not one
|
|
124
|
+
* of its own interfaces: a tunnel, a reverse proxy, a forwarded port.
|
|
125
|
+
*
|
|
126
|
+
* Without it a machine behind NAT has nothing to publish -- every address it
|
|
127
|
+
* can see is a 192.168 one that is no use to anybody else -- so the directory
|
|
128
|
+
* listing is skipped and the printed links only work inside the house.
|
|
129
|
+
*/
|
|
130
|
+
publicUrl: string;
|
|
122
131
|
/**
|
|
123
132
|
* Charge for listening once the stream is busy. Off unless asked for, and
|
|
124
133
|
* useless without somewhere to pay: see NIXAMP_PAY_TO.
|
|
@@ -168,6 +177,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
168
177
|
directory: false,
|
|
169
178
|
publish: "ask",
|
|
170
179
|
name: "",
|
|
180
|
+
publicUrl: process.env["NIXAMP_PUBLIC_URL"] ?? "",
|
|
171
181
|
x402: false,
|
|
172
182
|
owner: "",
|
|
173
183
|
ingest: false,
|
|
@@ -208,6 +218,14 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
208
218
|
options.publish = "yes";
|
|
209
219
|
} else if (arg === "--no-publish") {
|
|
210
220
|
options.publish = "no";
|
|
221
|
+
} else if (arg === "--public-url") {
|
|
222
|
+
const given = value().trim();
|
|
223
|
+
// A hostname on its own is the likely typo, and it fails much later --
|
|
224
|
+
// as a directory listing nobody can open -- so it is refused here.
|
|
225
|
+
if (!/^https?:\/\/[^\s/]+/i.test(given)) {
|
|
226
|
+
throw new Error("nixamp serve: --public-url must be a URL, e.g. https://nixamp.example.com");
|
|
227
|
+
}
|
|
228
|
+
options.publicUrl = given.replace(/\/+$/, "");
|
|
211
229
|
} else if (arg === "--name") {
|
|
212
230
|
options.name = value();
|
|
213
231
|
} else if (arg === "--owner") {
|
|
@@ -343,6 +361,16 @@ export interface Engine {
|
|
|
343
361
|
* dropping every listener.
|
|
344
362
|
*/
|
|
345
363
|
replace(tracks: Track[], root: string): void;
|
|
364
|
+
/**
|
|
365
|
+
* The same tracks, now with their tags.
|
|
366
|
+
*
|
|
367
|
+
* Startup lists filenames and begins serving immediately, because an ffprobe
|
|
368
|
+
* per file over a real library takes minutes; the tags arrive afterwards and
|
|
369
|
+
* land here. Unlike `replace` this must not disturb anything -- whoever is
|
|
370
|
+
* listening keeps listening, and the only visible change is that the titles
|
|
371
|
+
* fill in.
|
|
372
|
+
*/
|
|
373
|
+
retag(tracks: Track[], root: string): void;
|
|
346
374
|
stop(): void;
|
|
347
375
|
}
|
|
348
376
|
|
|
@@ -542,6 +570,18 @@ export class PlayerEngine implements Engine {
|
|
|
542
570
|
this.state.note = "";
|
|
543
571
|
this.push();
|
|
544
572
|
}
|
|
573
|
+
|
|
574
|
+
retag(tracks: Track[], root: string): void {
|
|
575
|
+
// Dropped rather than applied if the library moved underneath: somebody
|
|
576
|
+
// re-streamed while the tagging was still running, and these tags describe
|
|
577
|
+
// something nobody is playing any more.
|
|
578
|
+
if (root !== this.root || tracks.length !== this.tracks.length) return;
|
|
579
|
+
if (tracks.some((track, at) => track.path !== this.tracks[at]?.path)) return;
|
|
580
|
+
this.tracks = tracks;
|
|
581
|
+
// No stop, no index reset: the only thing that changes is what the titles
|
|
582
|
+
// say, and every remote finds out because a snapshot goes out.
|
|
583
|
+
this.push();
|
|
584
|
+
}
|
|
545
585
|
}
|
|
546
586
|
|
|
547
587
|
/** An engine with no library behind it, for the hosted PWA. */
|
|
@@ -559,6 +599,7 @@ export class EmptyEngine implements Engine {
|
|
|
559
599
|
return undefined;
|
|
560
600
|
}
|
|
561
601
|
replace(): void {}
|
|
602
|
+
retag(): void {}
|
|
562
603
|
stop(): void {}
|
|
563
604
|
}
|
|
564
605
|
|
|
@@ -2062,7 +2103,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2062
2103
|
const options = parseServeArgs(argv);
|
|
2063
2104
|
const root = isRemote(options.root) ? options.root : resolve(options.root);
|
|
2064
2105
|
const tools = detectTools();
|
|
2065
|
-
|
|
2106
|
+
// Names now, tags later.
|
|
2107
|
+
//
|
|
2108
|
+
// Reading tags is an ffprobe per file, which over a real library is minutes,
|
|
2109
|
+
// and every one of them used to happen before this process printed a word or
|
|
2110
|
+
// listened on a port. `nixamp serve ~/music` looked hung, and `nixamp daemon
|
|
2111
|
+
// start` was worse: it waits fifteen seconds for the announce line, killed a
|
|
2112
|
+
// daemon that was working perfectly, and reported a failure whose log was
|
|
2113
|
+
// empty because nothing had been written to it yet.
|
|
2114
|
+
//
|
|
2115
|
+
// So the filenames are enough to start: the server is up and answering in the
|
|
2116
|
+
// time it takes to walk the directory, and the titles fill in behind it.
|
|
2117
|
+
const tracks = await loadSource(tools, root, false);
|
|
2066
2118
|
const engine: Engine = tracks.length > 0
|
|
2067
2119
|
? new PlayerEngine(tracks, root, tools)
|
|
2068
2120
|
: new EmptyEngine(`No audio files under ${root}.`);
|
|
@@ -2338,16 +2390,52 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2338
2390
|
},
|
|
2339
2391
|
};
|
|
2340
2392
|
|
|
2393
|
+
// The link, not the address. Without the key the address is a 401, so
|
|
2394
|
+
// printing a bare host:port would be printing something that does not work.
|
|
2395
|
+
const addresses = reachableAddresses(options.host, port, options.publicUrl);
|
|
2396
|
+
|
|
2397
|
+
// Listening on every interface proves the socket is open here and nothing
|
|
2398
|
+
// about the path between here and the phone.
|
|
2399
|
+
const listening = options.host === "0.0.0.0" || options.host === "::";
|
|
2400
|
+
const firewall = listening ? firewallInUse(io) : null;
|
|
2401
|
+
|
|
2341
2402
|
if (options.announce) {
|
|
2342
|
-
|
|
2403
|
+
// Everything `nixamp daemon start` needs to print what this would have
|
|
2404
|
+
// printed. Without the addresses it could only reconstruct host and port,
|
|
2405
|
+
// which for a server bound to every interface means it printed 127.0.0.1 --
|
|
2406
|
+
// an address that works on exactly the machine you are already sitting at.
|
|
2407
|
+
// The firewall matters for the same reason: the warning was going into a
|
|
2408
|
+
// log file nobody reads instead of to the person who just typed the
|
|
2409
|
+
// command.
|
|
2410
|
+
console.log(
|
|
2411
|
+
JSON.stringify({
|
|
2412
|
+
nixamp: "listening",
|
|
2413
|
+
host: options.host,
|
|
2414
|
+
port,
|
|
2415
|
+
key,
|
|
2416
|
+
source: root,
|
|
2417
|
+
urls: addresses,
|
|
2418
|
+
firewall,
|
|
2419
|
+
}),
|
|
2420
|
+
);
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// The other half of "names now, tags later". It runs while the banner is
|
|
2424
|
+
// printed and while the publish prompt waits, and it is deliberately not
|
|
2425
|
+
// awaited: nothing downstream needs it, and a library that takes a minute to
|
|
2426
|
+
// read should cost nobody a minute of silence.
|
|
2427
|
+
if (tracks.length > 0 && !isRemote(root)) {
|
|
2428
|
+
void loadSource(tools, root, true)
|
|
2429
|
+
.then((tagged) => engine.retag(tagged, root))
|
|
2430
|
+
.catch(() => {
|
|
2431
|
+
// Filenames are a working player. A failure here is worth nothing but
|
|
2432
|
+
// titles that stay as they are.
|
|
2433
|
+
});
|
|
2343
2434
|
}
|
|
2344
2435
|
|
|
2345
2436
|
console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
|
|
2346
2437
|
console.log("");
|
|
2347
2438
|
|
|
2348
|
-
// The link, not the address. Without the key the address is a 401, so
|
|
2349
|
-
// printing a bare host:port would be printing something that does not work.
|
|
2350
|
-
const addresses = reachableAddresses(options.host, port);
|
|
2351
2439
|
const width = Math.max(...addresses.map((a) => a.label.length));
|
|
2352
2440
|
for (const { label, url } of addresses) {
|
|
2353
2441
|
console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
|
|
@@ -2404,10 +2492,6 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2404
2492
|
}
|
|
2405
2493
|
if (web === null) console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
|
|
2406
2494
|
|
|
2407
|
-
// Listening on every interface proves the socket is open here and nothing
|
|
2408
|
-
// about the path between here and the phone.
|
|
2409
|
-
const listening = options.host === "0.0.0.0" || options.host === "::";
|
|
2410
|
-
const firewall = listening ? firewallInUse(io) : null;
|
|
2411
2495
|
let closePort: (() => void) | null = null;
|
|
2412
2496
|
|
|
2413
2497
|
if (firewall !== null) {
|
package/src/share.ts
CHANGED
|
@@ -93,14 +93,24 @@ export function classify(address: string): "private" | "cgnat" | "public" {
|
|
|
93
93
|
* somewhere else can open. It is labelled for what it is, because the key in
|
|
94
94
|
* the link is then the only thing between a stranger and the library.
|
|
95
95
|
*/
|
|
96
|
-
export function reachableAddresses(
|
|
96
|
+
export function reachableAddresses(
|
|
97
|
+
host: string,
|
|
98
|
+
port: number,
|
|
99
|
+
publicUrl = "",
|
|
100
|
+
): { label: string; url: string }[] {
|
|
97
101
|
const link = (address: string): string => {
|
|
98
102
|
// A bare IPv6 address needs brackets before it is a URL.
|
|
99
103
|
const authority = address.includes(":") ? `[${address}]` : address;
|
|
100
104
|
return `http://${authority}:${port}`;
|
|
101
105
|
};
|
|
102
106
|
|
|
103
|
-
|
|
107
|
+
// An address somebody told us about, because it is one this machine cannot
|
|
108
|
+
// know: a tunnel, a reverse proxy, or a router forwarding a port. It goes
|
|
109
|
+
// first so that it, and not a guess from an interface, is the address this
|
|
110
|
+
// stream is published under.
|
|
111
|
+
const told = publicUrl ? [{ label: "on the internet", url: publicUrl.replace(/\/+$/, "") }] : [];
|
|
112
|
+
|
|
113
|
+
if (host !== "0.0.0.0" && host !== "::") return [...told, { label: "here", url: link(host) }];
|
|
104
114
|
|
|
105
115
|
const LABELS = { private: "on your network", cgnat: "on tailscale", public: "on the internet" } as const;
|
|
106
116
|
const found: { label: string; url: string; kind: keyof typeof LABELS }[] = [];
|
|
@@ -116,6 +126,7 @@ export function reachableAddresses(host: string, port: number): { label: string;
|
|
|
116
126
|
const order = { private: 0, cgnat: 1, public: 2 } as const;
|
|
117
127
|
found.sort((x, y) => order[x.kind] - order[y.kind]);
|
|
118
128
|
return [
|
|
129
|
+
...told,
|
|
119
130
|
{ label: "here", url: `http://localhost:${port}` },
|
|
120
131
|
...found.map(({ label, url }) => ({ label, url })),
|
|
121
132
|
];
|
package/web/dist/sw.js
CHANGED