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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a playlist comes from.
|
|
3
|
+
*
|
|
4
|
+
* A directory, a file, an .m3u, or a URL to any of those. ffmpeg reads a URL as
|
|
5
|
+
* happily as a path, so a remote track needs no special case once it is in the
|
|
6
|
+
* list; what needs care is telling the four apart, and telling an .m3u that
|
|
7
|
+
* lists tracks from an HLS playlist that *is* one track.
|
|
8
|
+
*/
|
|
9
|
+
/** http and https only. ffmpeg speaks more, but these are what a link is. */
|
|
10
|
+
export declare function isRemote(source: string): boolean;
|
|
11
|
+
export declare function isPlaylistFile(source: string): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* An HLS playlist describes one stream in segments; an .m3u describes a list of
|
|
14
|
+
* things to play. Both are "m3u8" on disk, and the tags are the only honest way
|
|
15
|
+
* to tell them apart. Expanding an HLS playlist into a track per segment would
|
|
16
|
+
* turn one song into four hundred.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isHls(text: string): boolean;
|
|
19
|
+
export interface Entry {
|
|
20
|
+
/** A path or a URL, whichever the playlist gave us. */
|
|
21
|
+
source: string;
|
|
22
|
+
title: string;
|
|
23
|
+
/** Seconds, from #EXTINF. Zero when it did not say, and for live. */
|
|
24
|
+
duration: number;
|
|
25
|
+
}
|
|
26
|
+
/** Resolve a playlist line against the playlist's own location. */
|
|
27
|
+
export declare function resolveEntry(base: string, entry: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Parse an .m3u or .m3u8. `#EXTINF:<seconds>,<title>` decorates the line after
|
|
30
|
+
* it; everything else beginning with # is a comment or a tag we do not need.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseM3u(text: string, base: string): Entry[];
|
|
33
|
+
/** A .pls, which Shoutcast and Icecast hand out as often as an .m3u. */
|
|
34
|
+
export declare function parsePls(text: string, base: string): Entry[];
|
|
35
|
+
/** The last useful part of a path or URL, for when nothing named the track. */
|
|
36
|
+
export declare function nameOf(source: string): string;
|
|
37
|
+
export declare function playsInBrowser(source: string): boolean;
|
package/dist/sources.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a playlist comes from.
|
|
3
|
+
*
|
|
4
|
+
* A directory, a file, an .m3u, or a URL to any of those. ffmpeg reads a URL as
|
|
5
|
+
* happily as a path, so a remote track needs no special case once it is in the
|
|
6
|
+
* list; what needs care is telling the four apart, and telling an .m3u that
|
|
7
|
+
* lists tracks from an HLS playlist that *is* one track.
|
|
8
|
+
*/
|
|
9
|
+
/** http and https only. ffmpeg speaks more, but these are what a link is. */
|
|
10
|
+
export function isRemote(source) {
|
|
11
|
+
return /^https?:\/\//i.test(source);
|
|
12
|
+
}
|
|
13
|
+
export function isPlaylistFile(source) {
|
|
14
|
+
const path = isRemote(source) ? new URL(source).pathname : source;
|
|
15
|
+
return /\.(m3u|m3u8|pls)$/i.test(path);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* An HLS playlist describes one stream in segments; an .m3u describes a list of
|
|
19
|
+
* things to play. Both are "m3u8" on disk, and the tags are the only honest way
|
|
20
|
+
* to tell them apart. Expanding an HLS playlist into a track per segment would
|
|
21
|
+
* turn one song into four hundred.
|
|
22
|
+
*/
|
|
23
|
+
export function isHls(text) {
|
|
24
|
+
return /^#EXT-X-(?:STREAM-INF|TARGETDURATION|MEDIA-SEQUENCE|PLAYLIST-TYPE|ENDLIST)/im.test(text);
|
|
25
|
+
}
|
|
26
|
+
/** Resolve a playlist line against the playlist's own location. */
|
|
27
|
+
export function resolveEntry(base, entry) {
|
|
28
|
+
if (isRemote(entry))
|
|
29
|
+
return entry;
|
|
30
|
+
if (isRemote(base))
|
|
31
|
+
return new URL(entry, base).toString();
|
|
32
|
+
if (entry.startsWith("/"))
|
|
33
|
+
return entry;
|
|
34
|
+
const dir = base.slice(0, Math.max(0, base.lastIndexOf("/")));
|
|
35
|
+
return dir ? `${dir}/${entry}` : entry;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse an .m3u or .m3u8. `#EXTINF:<seconds>,<title>` decorates the line after
|
|
39
|
+
* it; everything else beginning with # is a comment or a tag we do not need.
|
|
40
|
+
*/
|
|
41
|
+
export function parseM3u(text, base) {
|
|
42
|
+
const out = [];
|
|
43
|
+
let duration = 0;
|
|
44
|
+
let title = "";
|
|
45
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
46
|
+
const line = raw.trim();
|
|
47
|
+
if (line === "")
|
|
48
|
+
continue;
|
|
49
|
+
if (line.startsWith("#")) {
|
|
50
|
+
const info = /^#EXTINF:\s*(-?[\d.]+)\s*(?:,(.*))?$/i.exec(line);
|
|
51
|
+
if (info) {
|
|
52
|
+
const seconds = Number(info[1]);
|
|
53
|
+
// -1 is the conventional "unknown", which is also what live means.
|
|
54
|
+
duration = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
|
|
55
|
+
title = (info[2] ?? "").trim();
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const source = resolveEntry(base, line);
|
|
60
|
+
out.push({ source, duration, title: title || nameOf(source) });
|
|
61
|
+
duration = 0;
|
|
62
|
+
title = "";
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** A .pls, which Shoutcast and Icecast hand out as often as an .m3u. */
|
|
67
|
+
export function parsePls(text, base) {
|
|
68
|
+
const files = new Map();
|
|
69
|
+
const titles = new Map();
|
|
70
|
+
const lengths = new Map();
|
|
71
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
72
|
+
const line = raw.trim();
|
|
73
|
+
const match = /^(File|Title|Length)(\d+)\s*=\s*(.*)$/i.exec(line);
|
|
74
|
+
if (!match)
|
|
75
|
+
continue;
|
|
76
|
+
const [, kind, index, value] = match;
|
|
77
|
+
if (/^file$/i.test(kind))
|
|
78
|
+
files.set(index, value);
|
|
79
|
+
else if (/^title$/i.test(kind))
|
|
80
|
+
titles.set(index, value);
|
|
81
|
+
else
|
|
82
|
+
lengths.set(index, Number(value));
|
|
83
|
+
}
|
|
84
|
+
return [...files.entries()]
|
|
85
|
+
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
|
86
|
+
.map(([index, file]) => {
|
|
87
|
+
const source = resolveEntry(base, file);
|
|
88
|
+
const seconds = lengths.get(index) ?? 0;
|
|
89
|
+
return {
|
|
90
|
+
source,
|
|
91
|
+
title: titles.get(index)?.trim() || nameOf(source),
|
|
92
|
+
duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** The last useful part of a path or URL, for when nothing named the track. */
|
|
97
|
+
export function nameOf(source) {
|
|
98
|
+
const remote = isRemote(source);
|
|
99
|
+
const path = remote ? new URL(source).pathname : source;
|
|
100
|
+
const last = path.split("/").filter(Boolean).pop() ?? source;
|
|
101
|
+
// Percent-decoding is a URL's business. A file on disk called
|
|
102
|
+
// `Some%20Song.mp3` is called exactly that, and renaming it in the display
|
|
103
|
+
// would be a lie about what is in the directory.
|
|
104
|
+
if (!remote)
|
|
105
|
+
return last || source;
|
|
106
|
+
try {
|
|
107
|
+
return decodeURIComponent(last) || source;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return last || source;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Formats a browser will play as-is. Anything else gets transcoded on the way
|
|
115
|
+
* out, which is the difference between a library that plays on a phone and one
|
|
116
|
+
* that plays on the machine it lives on.
|
|
117
|
+
*/
|
|
118
|
+
const WEB_READY = new Set([".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".webm", ".mp4", ".wav"]);
|
|
119
|
+
export function playsInBrowser(source) {
|
|
120
|
+
if (isRemote(source))
|
|
121
|
+
return false;
|
|
122
|
+
const path = source.toLowerCase();
|
|
123
|
+
const dot = path.lastIndexOf(".");
|
|
124
|
+
return dot > 0 && WEB_READY.has(path.slice(dot));
|
|
125
|
+
}
|
package/package.json
CHANGED
package/src/admin.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
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, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
|
|
9
|
+
import type { Color } from "@profullstack/hqtui";
|
|
10
|
+
import type { Connection } from "./connections.ts";
|
|
11
|
+
import { daemonUrl, readState } from "./daemon.ts";
|
|
12
|
+
import { KEY_HEADER } from "./share.ts";
|
|
13
|
+
|
|
14
|
+
interface Report {
|
|
15
|
+
connections: Connection[];
|
|
16
|
+
active: number;
|
|
17
|
+
startedAt: number;
|
|
18
|
+
now: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Snapshot {
|
|
22
|
+
tracks: { title: string; artist: string; duration: number }[];
|
|
23
|
+
index: number;
|
|
24
|
+
playing: boolean;
|
|
25
|
+
position: number;
|
|
26
|
+
root: string;
|
|
27
|
+
note: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AdminOptions {
|
|
31
|
+
url: string;
|
|
32
|
+
key: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Where to point, from the flags or from the daemon that is running. */
|
|
36
|
+
export function resolveTarget(argv: string[]): AdminOptions {
|
|
37
|
+
const at = argv.findIndex((a) => a === "--url" || a === "-u");
|
|
38
|
+
const keyAt = argv.findIndex((a) => a === "--key");
|
|
39
|
+
const url = at === -1 ? null : argv[at + 1];
|
|
40
|
+
const key = keyAt === -1 ? null : (argv[keyAt + 1] ?? null);
|
|
41
|
+
|
|
42
|
+
if (url) return { url: url.replace(/\/+$/, ""), key };
|
|
43
|
+
|
|
44
|
+
const state = readState();
|
|
45
|
+
if (state === null) {
|
|
46
|
+
throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
|
|
47
|
+
}
|
|
48
|
+
return { url: daemonUrl(state), key: key ?? state.key };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Seconds as something a person reads at a glance. */
|
|
52
|
+
export function since(ms: number): string {
|
|
53
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
54
|
+
if (seconds < 60) return `${seconds}s`;
|
|
55
|
+
const minutes = Math.floor(seconds / 60);
|
|
56
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
57
|
+
const hours = Math.floor(minutes / 60);
|
|
58
|
+
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
59
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function bytes(value: number): string {
|
|
63
|
+
const units = ["B", "KiB", "MiB", "GiB"];
|
|
64
|
+
let n = value;
|
|
65
|
+
for (const unit of units) {
|
|
66
|
+
if (n < 1024 || unit === "GiB") return `${n < 10 && unit !== "B" ? n.toFixed(1) : Math.round(n)} ${unit}`;
|
|
67
|
+
n /= 1024;
|
|
68
|
+
}
|
|
69
|
+
return `${value} B`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The colour a network deserves: the internet is the one worth noticing. */
|
|
73
|
+
function networkColor(theme: Theme, network: Connection["network"]): number {
|
|
74
|
+
return network === "public"
|
|
75
|
+
? theme.warning
|
|
76
|
+
: network === "cgnat"
|
|
77
|
+
? theme.secondary
|
|
78
|
+
: network === "local"
|
|
79
|
+
? theme.muted
|
|
80
|
+
: theme.success;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function admin(argv: string[]): Promise<void> {
|
|
84
|
+
const target = resolveTarget(argv);
|
|
85
|
+
const headers: Record<string, string> = target.key ? { [KEY_HEADER]: target.key } : {};
|
|
86
|
+
|
|
87
|
+
const ask = async <T,>(path: string): Promise<T | null> => {
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(`${target.url}${path}`, { headers });
|
|
90
|
+
return response.ok ? ((await response.json()) as T) : null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
let report: Report | null = null;
|
|
97
|
+
let snapshot: Snapshot | null = null;
|
|
98
|
+
let error = "";
|
|
99
|
+
let restreaming = "";
|
|
100
|
+
let typing = false;
|
|
101
|
+
|
|
102
|
+
const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
|
|
103
|
+
|
|
104
|
+
const refresh = async (): Promise<void> => {
|
|
105
|
+
const [next, state] = await Promise.all([ask<Report>("/api/connections"), ask<Snapshot>("/api/state")]);
|
|
106
|
+
error = next === null ? `cannot reach ${target.url}` : "";
|
|
107
|
+
if (next) report = next;
|
|
108
|
+
if (state) snapshot = state;
|
|
109
|
+
app.invalidate();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const timer = setInterval(() => void refresh(), 1000);
|
|
113
|
+
await refresh();
|
|
114
|
+
|
|
115
|
+
app.on("key", (event: KeyEvent) => {
|
|
116
|
+
const key = event.key;
|
|
117
|
+
if (typing) {
|
|
118
|
+
if (key === "escape") { typing = false; restreaming = ""; }
|
|
119
|
+
else if (key === "enter") {
|
|
120
|
+
const url = restreaming.trim();
|
|
121
|
+
typing = false;
|
|
122
|
+
restreaming = "";
|
|
123
|
+
if (url) void restream(target, headers, url).then(() => refresh());
|
|
124
|
+
} else if (key === "backspace") restreaming = restreaming.slice(0, -1);
|
|
125
|
+
// A printable key is a character; everything else is a name like "f1".
|
|
126
|
+
else if (key.length === 1) restreaming += key;
|
|
127
|
+
app.invalidate();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (key === "q") { app.quit(); return; }
|
|
131
|
+
if (key === "r") { typing = true; app.invalidate(); }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
app.on("exit", () => clearInterval(timer));
|
|
135
|
+
app.render(({ ui, theme }) => draw(ui, theme, {
|
|
136
|
+
url: target.url, report, snapshot, error, typing, restreaming,
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
await app.start();
|
|
140
|
+
clearInterval(timer);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Ask the server to play something else, which is what re-streaming is. */
|
|
144
|
+
async function restream(target: AdminOptions, headers: Record<string, string>, url: string): Promise<void> {
|
|
145
|
+
try {
|
|
146
|
+
await fetch(`${target.url}/api/source`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ source: url }),
|
|
150
|
+
});
|
|
151
|
+
} catch {
|
|
152
|
+
// The next refresh reports the server being unreachable; this is not the
|
|
153
|
+
// place to make that noise.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface View {
|
|
158
|
+
url: string;
|
|
159
|
+
report: Report | null;
|
|
160
|
+
snapshot: Snapshot | null;
|
|
161
|
+
error: string;
|
|
162
|
+
typing: boolean;
|
|
163
|
+
restreaming: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function draw(ui: Container, theme: Theme, view: View): void {
|
|
167
|
+
const { report, snapshot } = view;
|
|
168
|
+
const now = report?.now ?? Date.now();
|
|
169
|
+
|
|
170
|
+
ui.row({ size: 7, gap: 1 }, (row) => {
|
|
171
|
+
row.panel({ title: "Server" }, (p) => {
|
|
172
|
+
p.text(view.url, { fg: theme.primary });
|
|
173
|
+
p.label(snapshot?.root ?? "—");
|
|
174
|
+
p.keyValues([
|
|
175
|
+
{ label: "Uptime", value: report ? since(now - report.startedAt) : "—", color: theme.accent },
|
|
176
|
+
{ label: "Tracks", value: String(snapshot?.tracks.length ?? 0), color: theme.foreground },
|
|
177
|
+
{ label: "Listeners", value: String(report?.active ?? 0), color: theme.success },
|
|
178
|
+
]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
row.panel({ title: "Now playing" }, (p) => {
|
|
182
|
+
const track = snapshot ? snapshot.tracks[snapshot.index] : undefined;
|
|
183
|
+
p.text(track?.title ?? "nothing", { fg: theme.accent });
|
|
184
|
+
p.label(track?.artist || "—");
|
|
185
|
+
p.keyValues([
|
|
186
|
+
{ label: "State", value: snapshot?.playing ? "playing" : "stopped", color: snapshot?.playing ? theme.success : theme.muted },
|
|
187
|
+
{ label: "Position", value: snapshot ? since(snapshot.position * 1000) : "—", color: theme.foreground },
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
ui.panel({ title: `Connections (${report?.active ?? 0} live)` }, (p) => {
|
|
193
|
+
if (view.error) {
|
|
194
|
+
p.text(view.error, { fg: theme.danger });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const rows = report?.connections ?? [];
|
|
198
|
+
if (rows.length === 0) {
|
|
199
|
+
p.label("Nobody is listening yet.");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A finished connection is drawn in muted colours rather than dropped: the
|
|
204
|
+
// most useful thing an admin view can say is "it stopped ten seconds ago".
|
|
205
|
+
const dim = (row: Connection, live: Color): Color => (row.endedAt === null ? live : theme.muted);
|
|
206
|
+
|
|
207
|
+
p.table<Connection>({
|
|
208
|
+
rows,
|
|
209
|
+
header: true,
|
|
210
|
+
headerColor: theme.muted,
|
|
211
|
+
zebra: false,
|
|
212
|
+
columns: [
|
|
213
|
+
{ key: "address", title: "Where", min: 12, color: (row) => dim(row, theme.foreground) },
|
|
214
|
+
{ key: "network", title: "Network", width: 9, color: (row) => dim(row, networkColor(theme, row.network)) },
|
|
215
|
+
{ key: "kind", title: "Kind", width: 7, color: theme.muted },
|
|
216
|
+
{ key: "agent", title: "Client", width: 12, color: theme.muted },
|
|
217
|
+
{ key: "track", title: "Track", min: 16, color: (row) => dim(row, theme.primary),
|
|
218
|
+
render: (row) => row.track || "—" },
|
|
219
|
+
{ key: "for", title: "For", width: 10, align: "right", color: theme.muted,
|
|
220
|
+
render: (row) => (row.endedAt === null
|
|
221
|
+
? since(now - row.startedAt)
|
|
222
|
+
: `${since(row.endedAt - row.startedAt)} ago`) },
|
|
223
|
+
{ key: "bytes", title: "Sent", width: 9, align: "right",
|
|
224
|
+
color: (row) => dim(row, theme.success), render: (row) => bytes(row.bytes) },
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (view.typing) {
|
|
230
|
+
ui.panel({ title: "Re-stream a URL or a path", size: 4 }, (p) => {
|
|
231
|
+
p.text(`${view.restreaming}_`, { fg: theme.accent });
|
|
232
|
+
p.label("Enter plays it here. Escape forgets it.");
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
ui.statusBar({
|
|
237
|
+
items: [
|
|
238
|
+
{ key: "r", label: "Re-stream" },
|
|
239
|
+
{ key: "q", label: "Quit" },
|
|
240
|
+
],
|
|
241
|
+
right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
|
|
242
|
+
});
|
|
243
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
import { classify } from "./share.ts";
|
|
11
|
+
|
|
12
|
+
export type Kind = "stream" | "media" | "events" | "page";
|
|
13
|
+
|
|
14
|
+
export interface Connection {
|
|
15
|
+
id: number;
|
|
16
|
+
kind: Kind;
|
|
17
|
+
/** The remote address, with an IPv4-mapped IPv6 prefix taken off. */
|
|
18
|
+
address: string;
|
|
19
|
+
/** Where that address lives: your network, tailscale, or the internet. */
|
|
20
|
+
network: "local" | "private" | "cgnat" | "public";
|
|
21
|
+
/** What is going down it, when we know. */
|
|
22
|
+
track: string;
|
|
23
|
+
/** Whatever the client called itself, trimmed to something printable. */
|
|
24
|
+
agent: string;
|
|
25
|
+
startedAt: number;
|
|
26
|
+
/**
|
|
27
|
+
* Bytes handed to the socket, which is not the same as bytes the listener
|
|
28
|
+
* has heard: the kernel buffers, and a slow client can be several seconds
|
|
29
|
+
* behind this number. It is the right figure for "is anything going out".
|
|
30
|
+
*/
|
|
31
|
+
bytes: number;
|
|
32
|
+
/** Set when it ends, so the admin view can show what just finished. */
|
|
33
|
+
endedAt: number | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** ::ffff:10.0.0.1 is 10.0.0.1 wearing a hat. */
|
|
37
|
+
export function normaliseAddress(address: string | undefined): string {
|
|
38
|
+
if (!address) return "unknown";
|
|
39
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
40
|
+
return mapped?.[1] ?? address;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Loopback is not "your network"; it is this machine. */
|
|
44
|
+
export function networkOf(address: string): Connection["network"] {
|
|
45
|
+
if (address === "127.0.0.1" || address === "::1" || address === "unknown") return "local";
|
|
46
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(address)) return "public";
|
|
47
|
+
return classify(address);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A user agent, cut to the part that identifies it. Browsers write a paragraph
|
|
52
|
+
* about every engine they have ever pretended to be.
|
|
53
|
+
*/
|
|
54
|
+
export function shortAgent(agent: string | undefined): string {
|
|
55
|
+
if (!agent) return "—";
|
|
56
|
+
const known = [
|
|
57
|
+
[/\bFirefox\/([\d.]+)/, "Firefox"],
|
|
58
|
+
[/\bEdg\/([\d.]+)/, "Edge"],
|
|
59
|
+
[/\bOPR\/([\d.]+)/, "Opera"],
|
|
60
|
+
[/\bChrome\/([\d.]+)/, "Chrome"],
|
|
61
|
+
[/\bVersion\/([\d.]+).*\bSafari\//, "Safari"],
|
|
62
|
+
[/\bVLC\/([\d.]+)/, "VLC"],
|
|
63
|
+
[/\bcurl\/([\d.]+)/, "curl"],
|
|
64
|
+
[/\bmpv\b/, "mpv"],
|
|
65
|
+
[/\bLavf\/([\d.]+)/, "ffmpeg"],
|
|
66
|
+
] as const;
|
|
67
|
+
for (const [pattern, name] of known) {
|
|
68
|
+
const found = pattern.exec(agent);
|
|
69
|
+
if (found) return found[1] ? `${name} ${found[1].split(".")[0]}` : name;
|
|
70
|
+
}
|
|
71
|
+
return agent.slice(0, 24);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The live set. Finished connections are kept for a while, because "it stopped
|
|
76
|
+
* ten seconds ago" is the most useful thing an admin view can tell you when
|
|
77
|
+
* someone says the stream dropped.
|
|
78
|
+
*/
|
|
79
|
+
export class Connections {
|
|
80
|
+
private next = 1;
|
|
81
|
+
private readonly items = new Map<number, Connection>();
|
|
82
|
+
/** How many finished connections to remember. */
|
|
83
|
+
constructor(private readonly keep = 50) {}
|
|
84
|
+
|
|
85
|
+
open(request: IncomingMessage, kind: Kind, track: string): Connection {
|
|
86
|
+
const address = normaliseAddress(request.socket.remoteAddress);
|
|
87
|
+
const connection: Connection = {
|
|
88
|
+
id: this.next++,
|
|
89
|
+
kind,
|
|
90
|
+
address,
|
|
91
|
+
network: networkOf(address),
|
|
92
|
+
track,
|
|
93
|
+
agent: shortAgent(request.headers["user-agent"]),
|
|
94
|
+
startedAt: Date.now(),
|
|
95
|
+
bytes: 0,
|
|
96
|
+
endedAt: null,
|
|
97
|
+
};
|
|
98
|
+
this.items.set(connection.id, connection);
|
|
99
|
+
return connection;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
close(id: number): void {
|
|
103
|
+
const found = this.items.get(id);
|
|
104
|
+
if (!found || found.endedAt !== null) return;
|
|
105
|
+
found.endedAt = Date.now();
|
|
106
|
+
this.prune();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
add(id: number, bytes: number): void {
|
|
110
|
+
const found = this.items.get(id);
|
|
111
|
+
if (found) found.bytes += bytes;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Live first, oldest connection at the top; then the most recently finished.
|
|
116
|
+
*
|
|
117
|
+
* The id breaks ties because Date.now() has millisecond resolution and ten
|
|
118
|
+
* connections can easily end inside one, which left the order down to
|
|
119
|
+
* whatever the sort happened to do.
|
|
120
|
+
*/
|
|
121
|
+
list(): Connection[] {
|
|
122
|
+
const all = [...this.items.values()];
|
|
123
|
+
const live = all
|
|
124
|
+
.filter((c) => c.endedAt === null)
|
|
125
|
+
.sort((a, b) => a.startedAt - b.startedAt || a.id - b.id);
|
|
126
|
+
const done = all
|
|
127
|
+
.filter((c) => c.endedAt !== null)
|
|
128
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || b.id - a.id);
|
|
129
|
+
return [...live, ...done];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get active(): number {
|
|
133
|
+
let count = 0;
|
|
134
|
+
for (const item of this.items.values()) if (item.endedAt === null) count++;
|
|
135
|
+
return count;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Drop the oldest finished entries once there are more than we keep. */
|
|
139
|
+
private prune(): void {
|
|
140
|
+
const done = [...this.items.values()]
|
|
141
|
+
.filter((c) => c.endedAt !== null)
|
|
142
|
+
.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0) || a.id - b.id);
|
|
143
|
+
for (const item of done.slice(0, Math.max(0, done.length - this.keep))) this.items.delete(item.id);
|
|
144
|
+
}
|
|
145
|
+
}
|