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/README.md
CHANGED
|
@@ -48,6 +48,18 @@ curl -fsSL https://nixamp.com/install.sh | sh -s -- --version 0.1.0
|
|
|
48
48
|
curl -fsSL https://nixamp.com/install.sh | sh -s -- --prefix ~/opt
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
On Windows, in PowerShell:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
irm https://nixamp.com/install.ps1 | iex
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That lands under `%LOCALAPPDATA%\nixamp`, adds itself to your user PATH, and
|
|
58
|
+
needs no administrator rights. Nothing is code signed, so SmartScreen will warn
|
|
59
|
+
the first time.
|
|
60
|
+
|
|
61
|
+
Builds are published for Linux, macOS and Windows, on both x64 and arm64.
|
|
62
|
+
|
|
51
63
|
Then:
|
|
52
64
|
|
|
53
65
|
```
|
|
@@ -63,6 +75,48 @@ network. Your music is never touched.
|
|
|
63
75
|
If you would rather not pipe a script into a shell, `npm i -g nixamp` and
|
|
64
76
|
`bunx nixamp ~/Music` both work; that route needs Node 24 or newer.
|
|
65
77
|
|
|
78
|
+
## Leaving it running
|
|
79
|
+
|
|
80
|
+
`nixamp serve` holds a terminal. `nixamp daemon` does not.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
nixamp daemon start ~/Music --open-port
|
|
84
|
+
nixamp daemon status
|
|
85
|
+
nixamp daemon stop
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Start writes down where it went and the key it minted, waits until the server
|
|
89
|
+
is actually answering before saying it started, and prints the share link. It is
|
|
90
|
+
one daemon per user, and the state lives in `$XDG_STATE_HOME/nixamp`.
|
|
91
|
+
|
|
92
|
+
## Watching it
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
nixamp admin
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Who is connected, from where, to what, for how long and how much has gone out.
|
|
99
|
+
It reads the daemon's own state file, so it needs no arguments; point it
|
|
100
|
+
anywhere else with `--url` and `--key`.
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
╭─ Server ─────────────────────────╮ ╭─ Now playing ────────────────────╮
|
|
104
|
+
│ http://127.0.0.1:4321 │ │ long.flac │
|
|
105
|
+
│ /home/anthony/Music │ │ — │
|
|
106
|
+
│ Uptime 3s │ │ State stopped │
|
|
107
|
+
│ Tracks 1 │ │ Position 0s │
|
|
108
|
+
│ Listeners 2 │ │ │
|
|
109
|
+
╰──────────────────────────────────╯ ╰──────────────────────────────────╯
|
|
110
|
+
╭─ Connections (2 live) ────────────────────────────────────────────────╮
|
|
111
|
+
│ Where Network Kind Client Track For Sent │
|
|
112
|
+
│ 10.0.0.42 private media VLC 3 long.flac 3s 2.6 MiB │
|
|
113
|
+
│ 100.65.1.7 tailscale stream Safari 17 long.flac 41s 18 MiB │
|
|
114
|
+
╰───────────────────────────────────────────────────────────────────────╯
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Press `r` to re-stream: hand the running server a different URL or path and the
|
|
118
|
+
listeners stay connected while what they are hearing changes under them.
|
|
119
|
+
|
|
66
120
|
## How it works
|
|
67
121
|
|
|
68
122
|
One decode feeds both your speakers and the display. `ffmpeg` writes raw 32-bit float samples to a pipe; nixamp reads every sample on its way past, runs an FFT over it, and hands the same bytes to `ffplay`.
|
package/bin/nixamp.mjs
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { main } from "../dist/main.js";
|
|
3
3
|
|
|
4
4
|
main().catch((error) => {
|
|
5
|
-
|
|
5
|
+
// A message we wrote is a message the user can act on; anything else is a
|
|
6
|
+
// bug and deserves its stack.
|
|
7
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8
|
+
console.error(message.startsWith("nixamp:") ? message : error);
|
|
6
9
|
process.exit(1);
|
|
7
10
|
});
|
package/dist/admin.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp admin` — what the daemon is doing, and who is listening to it.
|
|
3
|
+
*
|
|
4
|
+
* It talks to a running server over the same HTTP API a browser uses, so it
|
|
5
|
+
* works against the local daemon, against `nixamp serve` in another terminal,
|
|
6
|
+
* or against a nixamp on a different machine entirely.
|
|
7
|
+
*/
|
|
8
|
+
import { type Container, type Theme } from "@profullstack/hqtui";
|
|
9
|
+
import type { Connection } from "./connections.ts";
|
|
10
|
+
interface Report {
|
|
11
|
+
connections: Connection[];
|
|
12
|
+
active: number;
|
|
13
|
+
startedAt: number;
|
|
14
|
+
now: number;
|
|
15
|
+
}
|
|
16
|
+
interface Snapshot {
|
|
17
|
+
tracks: {
|
|
18
|
+
title: string;
|
|
19
|
+
artist: string;
|
|
20
|
+
duration: number;
|
|
21
|
+
}[];
|
|
22
|
+
index: number;
|
|
23
|
+
playing: boolean;
|
|
24
|
+
position: number;
|
|
25
|
+
root: string;
|
|
26
|
+
note: string;
|
|
27
|
+
}
|
|
28
|
+
export interface AdminOptions {
|
|
29
|
+
url: string;
|
|
30
|
+
key: string | null;
|
|
31
|
+
}
|
|
32
|
+
/** Where to point, from the flags or from the daemon that is running. */
|
|
33
|
+
export declare function resolveTarget(argv: string[]): AdminOptions;
|
|
34
|
+
/** Seconds as something a person reads at a glance. */
|
|
35
|
+
export declare function since(ms: number): string;
|
|
36
|
+
export declare function bytes(value: number): string;
|
|
37
|
+
export declare function admin(argv: string[]): Promise<void>;
|
|
38
|
+
export interface View {
|
|
39
|
+
url: string;
|
|
40
|
+
report: Report | null;
|
|
41
|
+
snapshot: Snapshot | null;
|
|
42
|
+
error: string;
|
|
43
|
+
typing: boolean;
|
|
44
|
+
restreaming: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function draw(ui: Container, theme: Theme, view: View): void;
|
|
47
|
+
export {};
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp admin` — what the daemon is doing, and who is listening to it.
|
|
3
|
+
*
|
|
4
|
+
* It talks to a running server over the same HTTP API a browser uses, so it
|
|
5
|
+
* works against the local daemon, against `nixamp serve` in another terminal,
|
|
6
|
+
* or against a nixamp on a different machine entirely.
|
|
7
|
+
*/
|
|
8
|
+
import { createApp, themes } from "@profullstack/hqtui";
|
|
9
|
+
import { daemonUrl, readState } from "./daemon.js";
|
|
10
|
+
import { KEY_HEADER } from "./share.js";
|
|
11
|
+
/** Where to point, from the flags or from the daemon that is running. */
|
|
12
|
+
export function resolveTarget(argv) {
|
|
13
|
+
const at = argv.findIndex((a) => a === "--url" || a === "-u");
|
|
14
|
+
const keyAt = argv.findIndex((a) => a === "--key");
|
|
15
|
+
const url = at === -1 ? null : argv[at + 1];
|
|
16
|
+
const key = keyAt === -1 ? null : (argv[keyAt + 1] ?? null);
|
|
17
|
+
if (url)
|
|
18
|
+
return { url: url.replace(/\/+$/, ""), key };
|
|
19
|
+
const state = readState();
|
|
20
|
+
if (state === null) {
|
|
21
|
+
throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
|
|
22
|
+
}
|
|
23
|
+
return { url: daemonUrl(state), key: key ?? state.key };
|
|
24
|
+
}
|
|
25
|
+
/** Seconds as something a person reads at a glance. */
|
|
26
|
+
export function since(ms) {
|
|
27
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
28
|
+
if (seconds < 60)
|
|
29
|
+
return `${seconds}s`;
|
|
30
|
+
const minutes = Math.floor(seconds / 60);
|
|
31
|
+
if (minutes < 60)
|
|
32
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
33
|
+
const hours = Math.floor(minutes / 60);
|
|
34
|
+
if (hours < 24)
|
|
35
|
+
return `${hours}h ${minutes % 60}m`;
|
|
36
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
37
|
+
}
|
|
38
|
+
export function bytes(value) {
|
|
39
|
+
const units = ["B", "KiB", "MiB", "GiB"];
|
|
40
|
+
let n = value;
|
|
41
|
+
for (const unit of units) {
|
|
42
|
+
if (n < 1024 || unit === "GiB")
|
|
43
|
+
return `${n < 10 && unit !== "B" ? n.toFixed(1) : Math.round(n)} ${unit}`;
|
|
44
|
+
n /= 1024;
|
|
45
|
+
}
|
|
46
|
+
return `${value} B`;
|
|
47
|
+
}
|
|
48
|
+
/** The colour a network deserves: the internet is the one worth noticing. */
|
|
49
|
+
function networkColor(theme, network) {
|
|
50
|
+
return network === "public"
|
|
51
|
+
? theme.warning
|
|
52
|
+
: network === "cgnat"
|
|
53
|
+
? theme.secondary
|
|
54
|
+
: network === "local"
|
|
55
|
+
? theme.muted
|
|
56
|
+
: theme.success;
|
|
57
|
+
}
|
|
58
|
+
export async function admin(argv) {
|
|
59
|
+
const target = resolveTarget(argv);
|
|
60
|
+
const headers = target.key ? { [KEY_HEADER]: target.key } : {};
|
|
61
|
+
const ask = async (path) => {
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(`${target.url}${path}`, { headers });
|
|
64
|
+
return response.ok ? (await response.json()) : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
let report = null;
|
|
71
|
+
let snapshot = null;
|
|
72
|
+
let error = "";
|
|
73
|
+
let restreaming = "";
|
|
74
|
+
let typing = false;
|
|
75
|
+
const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
|
|
76
|
+
const refresh = async () => {
|
|
77
|
+
const [next, state] = await Promise.all([ask("/api/connections"), ask("/api/state")]);
|
|
78
|
+
error = next === null ? `cannot reach ${target.url}` : "";
|
|
79
|
+
if (next)
|
|
80
|
+
report = next;
|
|
81
|
+
if (state)
|
|
82
|
+
snapshot = state;
|
|
83
|
+
app.invalidate();
|
|
84
|
+
};
|
|
85
|
+
const timer = setInterval(() => void refresh(), 1000);
|
|
86
|
+
await refresh();
|
|
87
|
+
app.on("key", (event) => {
|
|
88
|
+
const key = event.key;
|
|
89
|
+
if (typing) {
|
|
90
|
+
if (key === "escape") {
|
|
91
|
+
typing = false;
|
|
92
|
+
restreaming = "";
|
|
93
|
+
}
|
|
94
|
+
else if (key === "enter") {
|
|
95
|
+
const url = restreaming.trim();
|
|
96
|
+
typing = false;
|
|
97
|
+
restreaming = "";
|
|
98
|
+
if (url)
|
|
99
|
+
void restream(target, headers, url).then(() => refresh());
|
|
100
|
+
}
|
|
101
|
+
else if (key === "backspace")
|
|
102
|
+
restreaming = restreaming.slice(0, -1);
|
|
103
|
+
// A printable key is a character; everything else is a name like "f1".
|
|
104
|
+
else if (key.length === 1)
|
|
105
|
+
restreaming += key;
|
|
106
|
+
app.invalidate();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (key === "q") {
|
|
110
|
+
app.quit();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key === "r") {
|
|
114
|
+
typing = true;
|
|
115
|
+
app.invalidate();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
app.on("exit", () => clearInterval(timer));
|
|
119
|
+
app.render(({ ui, theme }) => draw(ui, theme, {
|
|
120
|
+
url: target.url, report, snapshot, error, typing, restreaming,
|
|
121
|
+
}));
|
|
122
|
+
await app.start();
|
|
123
|
+
clearInterval(timer);
|
|
124
|
+
}
|
|
125
|
+
/** Ask the server to play something else, which is what re-streaming is. */
|
|
126
|
+
async function restream(target, headers, url) {
|
|
127
|
+
try {
|
|
128
|
+
await fetch(`${target.url}/api/source`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
131
|
+
body: JSON.stringify({ source: url }),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// The next refresh reports the server being unreachable; this is not the
|
|
136
|
+
// place to make that noise.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function draw(ui, theme, view) {
|
|
140
|
+
const { report, snapshot } = view;
|
|
141
|
+
const now = report?.now ?? Date.now();
|
|
142
|
+
ui.row({ size: 7, gap: 1 }, (row) => {
|
|
143
|
+
row.panel({ title: "Server" }, (p) => {
|
|
144
|
+
p.text(view.url, { fg: theme.primary });
|
|
145
|
+
p.label(snapshot?.root ?? "—");
|
|
146
|
+
p.keyValues([
|
|
147
|
+
{ label: "Uptime", value: report ? since(now - report.startedAt) : "—", color: theme.accent },
|
|
148
|
+
{ label: "Tracks", value: String(snapshot?.tracks.length ?? 0), color: theme.foreground },
|
|
149
|
+
{ label: "Listeners", value: String(report?.active ?? 0), color: theme.success },
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
row.panel({ title: "Now playing" }, (p) => {
|
|
153
|
+
const track = snapshot ? snapshot.tracks[snapshot.index] : undefined;
|
|
154
|
+
p.text(track?.title ?? "nothing", { fg: theme.accent });
|
|
155
|
+
p.label(track?.artist || "—");
|
|
156
|
+
p.keyValues([
|
|
157
|
+
{ label: "State", value: snapshot?.playing ? "playing" : "stopped", color: snapshot?.playing ? theme.success : theme.muted },
|
|
158
|
+
{ label: "Position", value: snapshot ? since(snapshot.position * 1000) : "—", color: theme.foreground },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
ui.panel({ title: `Connections (${report?.active ?? 0} live)` }, (p) => {
|
|
163
|
+
if (view.error) {
|
|
164
|
+
p.text(view.error, { fg: theme.danger });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const rows = report?.connections ?? [];
|
|
168
|
+
if (rows.length === 0) {
|
|
169
|
+
p.label("Nobody is listening yet.");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// A finished connection is drawn in muted colours rather than dropped: the
|
|
173
|
+
// most useful thing an admin view can say is "it stopped ten seconds ago".
|
|
174
|
+
const dim = (row, live) => (row.endedAt === null ? live : theme.muted);
|
|
175
|
+
p.table({
|
|
176
|
+
rows,
|
|
177
|
+
header: true,
|
|
178
|
+
headerColor: theme.muted,
|
|
179
|
+
zebra: false,
|
|
180
|
+
columns: [
|
|
181
|
+
{ key: "address", title: "Where", min: 12, color: (row) => dim(row, theme.foreground) },
|
|
182
|
+
{ key: "network", title: "Network", width: 9, color: (row) => dim(row, networkColor(theme, row.network)) },
|
|
183
|
+
{ key: "kind", title: "Kind", width: 7, color: theme.muted },
|
|
184
|
+
{ key: "agent", title: "Client", width: 12, color: theme.muted },
|
|
185
|
+
{ key: "track", title: "Track", min: 16, color: (row) => dim(row, theme.primary),
|
|
186
|
+
render: (row) => row.track || "—" },
|
|
187
|
+
{ key: "for", title: "For", width: 10, align: "right", color: theme.muted,
|
|
188
|
+
render: (row) => (row.endedAt === null
|
|
189
|
+
? since(now - row.startedAt)
|
|
190
|
+
: `${since(row.endedAt - row.startedAt)} ago`) },
|
|
191
|
+
{ key: "bytes", title: "Sent", width: 9, align: "right",
|
|
192
|
+
color: (row) => dim(row, theme.success), render: (row) => bytes(row.bytes) },
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
if (view.typing) {
|
|
197
|
+
ui.panel({ title: "Re-stream a URL or a path", size: 4 }, (p) => {
|
|
198
|
+
p.text(`${view.restreaming}_`, { fg: theme.accent });
|
|
199
|
+
p.label("Enter plays it here. Escape forgets it.");
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
ui.statusBar({
|
|
203
|
+
items: [
|
|
204
|
+
{ key: "r", label: "Re-stream" },
|
|
205
|
+
{ key: "q", label: "Quit" },
|
|
206
|
+
],
|
|
207
|
+
right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is listening.
|
|
3
|
+
*
|
|
4
|
+
* A stream is a long-lived request, so the server can say exactly who is
|
|
5
|
+
* connected, to what, since when and how much has gone out. That is the whole
|
|
6
|
+
* point of the admin view: `ss -tn` tells you a socket exists, and nothing
|
|
7
|
+
* about which track is going down it.
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage } from "node:http";
|
|
10
|
+
export type Kind = "stream" | "media" | "events" | "page";
|
|
11
|
+
export interface Connection {
|
|
12
|
+
id: number;
|
|
13
|
+
kind: Kind;
|
|
14
|
+
/** The remote address, with an IPv4-mapped IPv6 prefix taken off. */
|
|
15
|
+
address: string;
|
|
16
|
+
/** Where that address lives: your network, tailscale, or the internet. */
|
|
17
|
+
network: "local" | "private" | "cgnat" | "public";
|
|
18
|
+
/** What is going down it, when we know. */
|
|
19
|
+
track: string;
|
|
20
|
+
/** Whatever the client called itself, trimmed to something printable. */
|
|
21
|
+
agent: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
/**
|
|
24
|
+
* Bytes handed to the socket, which is not the same as bytes the listener
|
|
25
|
+
* has heard: the kernel buffers, and a slow client can be several seconds
|
|
26
|
+
* behind this number. It is the right figure for "is anything going out".
|
|
27
|
+
*/
|
|
28
|
+
bytes: number;
|
|
29
|
+
/** Set when it ends, so the admin view can show what just finished. */
|
|
30
|
+
endedAt: number | null;
|
|
31
|
+
}
|
|
32
|
+
/** ::ffff:10.0.0.1 is 10.0.0.1 wearing a hat. */
|
|
33
|
+
export declare function normaliseAddress(address: string | undefined): string;
|
|
34
|
+
/** Loopback is not "your network"; it is this machine. */
|
|
35
|
+
export declare function networkOf(address: string): Connection["network"];
|
|
36
|
+
/**
|
|
37
|
+
* A user agent, cut to the part that identifies it. Browsers write a paragraph
|
|
38
|
+
* about every engine they have ever pretended to be.
|
|
39
|
+
*/
|
|
40
|
+
export declare function shortAgent(agent: string | undefined): string;
|
|
41
|
+
/**
|
|
42
|
+
* The live set. Finished connections are kept for a while, because "it stopped
|
|
43
|
+
* ten seconds ago" is the most useful thing an admin view can tell you when
|
|
44
|
+
* someone says the stream dropped.
|
|
45
|
+
*/
|
|
46
|
+
export declare class Connections {
|
|
47
|
+
private readonly keep;
|
|
48
|
+
private next;
|
|
49
|
+
private readonly items;
|
|
50
|
+
/** How many finished connections to remember. */
|
|
51
|
+
constructor(keep?: number);
|
|
52
|
+
open(request: IncomingMessage, kind: Kind, track: string): Connection;
|
|
53
|
+
close(id: number): void;
|
|
54
|
+
add(id: number, bytes: number): void;
|
|
55
|
+
/**
|
|
56
|
+
* Live first, oldest connection at the top; then the most recently finished.
|
|
57
|
+
*
|
|
58
|
+
* The id breaks ties because Date.now() has millisecond resolution and ten
|
|
59
|
+
* connections can easily end inside one, which left the order down to
|
|
60
|
+
* whatever the sort happened to do.
|
|
61
|
+
*/
|
|
62
|
+
list(): Connection[];
|
|
63
|
+
get active(): number;
|
|
64
|
+
/** Drop the oldest finished entries once there are more than we keep. */
|
|
65
|
+
private prune;
|
|
66
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { classify } from "./share.js";
|
|
2
|
+
/** ::ffff:10.0.0.1 is 10.0.0.1 wearing a hat. */
|
|
3
|
+
export function normaliseAddress(address) {
|
|
4
|
+
if (!address)
|
|
5
|
+
return "unknown";
|
|
6
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
7
|
+
return mapped?.[1] ?? address;
|
|
8
|
+
}
|
|
9
|
+
/** Loopback is not "your network"; it is this machine. */
|
|
10
|
+
export function networkOf(address) {
|
|
11
|
+
if (address === "127.0.0.1" || address === "::1" || address === "unknown")
|
|
12
|
+
return "local";
|
|
13
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(address))
|
|
14
|
+
return "public";
|
|
15
|
+
return classify(address);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A user agent, cut to the part that identifies it. Browsers write a paragraph
|
|
19
|
+
* about every engine they have ever pretended to be.
|
|
20
|
+
*/
|
|
21
|
+
export function shortAgent(agent) {
|
|
22
|
+
if (!agent)
|
|
23
|
+
return "—";
|
|
24
|
+
const known = [
|
|
25
|
+
[/\bFirefox\/([\d.]+)/, "Firefox"],
|
|
26
|
+
[/\bEdg\/([\d.]+)/, "Edge"],
|
|
27
|
+
[/\bOPR\/([\d.]+)/, "Opera"],
|
|
28
|
+
[/\bChrome\/([\d.]+)/, "Chrome"],
|
|
29
|
+
[/\bVersion\/([\d.]+).*\bSafari\//, "Safari"],
|
|
30
|
+
[/\bVLC\/([\d.]+)/, "VLC"],
|
|
31
|
+
[/\bcurl\/([\d.]+)/, "curl"],
|
|
32
|
+
[/\bmpv\b/, "mpv"],
|
|
33
|
+
[/\bLavf\/([\d.]+)/, "ffmpeg"],
|
|
34
|
+
];
|
|
35
|
+
for (const [pattern, name] of known) {
|
|
36
|
+
const found = pattern.exec(agent);
|
|
37
|
+
if (found)
|
|
38
|
+
return found[1] ? `${name} ${found[1].split(".")[0]}` : name;
|
|
39
|
+
}
|
|
40
|
+
return agent.slice(0, 24);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The live set. Finished connections are kept for a while, because "it stopped
|
|
44
|
+
* ten seconds ago" is the most useful thing an admin view can tell you when
|
|
45
|
+
* someone says the stream dropped.
|
|
46
|
+
*/
|
|
47
|
+
export class Connections {
|
|
48
|
+
keep;
|
|
49
|
+
next = 1;
|
|
50
|
+
items = new Map();
|
|
51
|
+
/** How many finished connections to remember. */
|
|
52
|
+
constructor(keep = 50) {
|
|
53
|
+
this.keep = keep;
|
|
54
|
+
}
|
|
55
|
+
open(request, kind, track) {
|
|
56
|
+
const address = normaliseAddress(request.socket.remoteAddress);
|
|
57
|
+
const connection = {
|
|
58
|
+
id: this.next++,
|
|
59
|
+
kind,
|
|
60
|
+
address,
|
|
61
|
+
network: networkOf(address),
|
|
62
|
+
track,
|
|
63
|
+
agent: shortAgent(request.headers["user-agent"]),
|
|
64
|
+
startedAt: Date.now(),
|
|
65
|
+
bytes: 0,
|
|
66
|
+
endedAt: null,
|
|
67
|
+
};
|
|
68
|
+
this.items.set(connection.id, connection);
|
|
69
|
+
return connection;
|
|
70
|
+
}
|
|
71
|
+
close(id) {
|
|
72
|
+
const found = this.items.get(id);
|
|
73
|
+
if (!found || found.endedAt !== null)
|
|
74
|
+
return;
|
|
75
|
+
found.endedAt = Date.now();
|
|
76
|
+
this.prune();
|
|
77
|
+
}
|
|
78
|
+
add(id, bytes) {
|
|
79
|
+
const found = this.items.get(id);
|
|
80
|
+
if (found)
|
|
81
|
+
found.bytes += bytes;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Live first, oldest connection at the top; then the most recently finished.
|
|
85
|
+
*
|
|
86
|
+
* The id breaks ties because Date.now() has millisecond resolution and ten
|
|
87
|
+
* connections can easily end inside one, which left the order down to
|
|
88
|
+
* whatever the sort happened to do.
|
|
89
|
+
*/
|
|
90
|
+
list() {
|
|
91
|
+
const all = [...this.items.values()];
|
|
92
|
+
const live = all
|
|
93
|
+
.filter((c) => c.endedAt === null)
|
|
94
|
+
.sort((a, b) => a.startedAt - b.startedAt || a.id - b.id);
|
|
95
|
+
const done = all
|
|
96
|
+
.filter((c) => c.endedAt !== null)
|
|
97
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || b.id - a.id);
|
|
98
|
+
return [...live, ...done];
|
|
99
|
+
}
|
|
100
|
+
get active() {
|
|
101
|
+
let count = 0;
|
|
102
|
+
for (const item of this.items.values())
|
|
103
|
+
if (item.endedAt === null)
|
|
104
|
+
count++;
|
|
105
|
+
return count;
|
|
106
|
+
}
|
|
107
|
+
/** Drop the oldest finished entries once there are more than we keep. */
|
|
108
|
+
prune() {
|
|
109
|
+
const done = [...this.items.values()]
|
|
110
|
+
.filter((c) => c.endedAt !== null)
|
|
111
|
+
.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0) || a.id - b.id);
|
|
112
|
+
for (const item of done.slice(0, Math.max(0, done.length - this.keep)))
|
|
113
|
+
this.items.delete(item.id);
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface DaemonState {
|
|
2
|
+
pid: number;
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
/** The share key, so `nixamp admin` can talk to it without being told. */
|
|
6
|
+
key: string | null;
|
|
7
|
+
source: string;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
/** Where its output went, for when it died and you want to know why. */
|
|
10
|
+
log: string;
|
|
11
|
+
}
|
|
12
|
+
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
13
|
+
export declare function stateDir(): string;
|
|
14
|
+
export declare function statePath(): string;
|
|
15
|
+
export declare function logPath(): string;
|
|
16
|
+
export declare function readState(): DaemonState | null;
|
|
17
|
+
export declare function writeState(state: DaemonState): void;
|
|
18
|
+
export declare function clearState(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Signal 0 asks "could I signal this?" without sending anything. A pid file
|
|
21
|
+
* outlives the process that wrote it often enough that trusting one is how you
|
|
22
|
+
* end up reporting a daemon that has been dead since Tuesday.
|
|
23
|
+
*/
|
|
24
|
+
export declare function alive(pid: number): boolean;
|
|
25
|
+
/** The daemon as far as anyone asking is concerned. */
|
|
26
|
+
export declare function status(): {
|
|
27
|
+
running: boolean;
|
|
28
|
+
state: DaemonState | null;
|
|
29
|
+
};
|
|
30
|
+
/** The URL an admin client should talk to. */
|
|
31
|
+
export declare function daemonUrl(state: DaemonState): string;
|
|
32
|
+
/**
|
|
33
|
+
* Start one, detached, and wait until it is actually answering before saying
|
|
34
|
+
* it started. Reporting success and leaving the user to discover a crash in a
|
|
35
|
+
* log file is the thing this is meant to avoid.
|
|
36
|
+
*/
|
|
37
|
+
export declare function start(argv: string[], entry: string): Promise<DaemonState>;
|
|
38
|
+
/** Stop it, and wait for it to actually be gone. */
|
|
39
|
+
export declare function stop(timeoutMs?: number): Promise<boolean>;
|