nixamp 0.1.0 → 0.3.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 +225 -0
- package/bin/nixamp.mjs +4 -1
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -0
- package/dist/admin.d.ts +47 -0
- package/dist/admin.js +209 -0
- package/dist/broadcast.d.ts +96 -0
- package/dist/broadcast.js +193 -0
- package/dist/channels.d.ts +94 -0
- package/dist/channels.js +235 -0
- package/dist/connections.d.ts +72 -0
- package/dist/connections.js +128 -0
- package/dist/daemon.d.ts +39 -0
- package/dist/daemon.js +170 -0
- package/dist/directory.d.ts +63 -0
- package/dist/directory.js +111 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +103 -6
- package/dist/manage.js +30 -7
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/playlist.d.ts +16 -0
- package/dist/playlist.js +57 -2
- package/dist/publish.d.ts +36 -0
- package/dist/publish.js +90 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +117 -4
- package/dist/server.js +923 -24
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +74 -0
- package/dist/share.js +172 -0
- package/dist/sources.d.ts +37 -0
- package/dist/sources.js +125 -0
- package/package.json +5 -2
- package/src/accounts.ts +193 -0
- package/src/admin.ts +243 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +158 -0
- package/src/daemon.ts +193 -0
- package/src/directory.ts +135 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +107 -6
- package/src/manage.ts +35 -7
- package/src/owner.ts +113 -0
- package/src/paywall.ts +198 -0
- package/src/playlist.ts +68 -2
- package/src/publish.ts +101 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +1087 -23
- package/src/session.ts +209 -0
- package/src/share.ts +193 -0
- package/src/sources.ts +136 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
- package/web/dist/assets/index-WYJ6R4uF.js +1 -0
- package/web/dist/index.html +37 -6
- package/web/dist/install.ps1 +214 -0
- package/web/dist/sw.js +3 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/src/directory.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public directory.
|
|
3
|
+
*
|
|
4
|
+
* A nixamp that agrees to be listed announces itself to nixamp.com every so
|
|
5
|
+
* often and is forgotten when it stops. There is no database behind it: an
|
|
6
|
+
* entry lives for a few minutes and a heartbeat renews it, so a restart of the
|
|
7
|
+
* directory costs one heartbeat rather than a migration, and a stream that
|
|
8
|
+
* dies falls out of the list without anyone having to notice.
|
|
9
|
+
*
|
|
10
|
+
* What is published is the *listen* link. The control key never leaves the
|
|
11
|
+
* machine it was minted on.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** How long an entry survives without a heartbeat. */
|
|
15
|
+
export const TTL_MS = 4 * 60 * 1000;
|
|
16
|
+
/** How often a publisher renews. Comfortably inside the TTL. */
|
|
17
|
+
export const HEARTBEAT_MS = 90 * 1000;
|
|
18
|
+
export const DEFAULT_DIRECTORY = "https://nixamp.com";
|
|
19
|
+
|
|
20
|
+
export interface Listing {
|
|
21
|
+
/** Assigned by the directory, so a publisher cannot claim someone else's. */
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
/** The listen link, which is what a browser opens. */
|
|
25
|
+
url: string;
|
|
26
|
+
tracks: number;
|
|
27
|
+
nowPlaying: string;
|
|
28
|
+
/** Set by the directory from the request, never by the publisher. */
|
|
29
|
+
updatedAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What a publisher sends. Everything else about a listing is ours to decide. */
|
|
33
|
+
export interface Announcement {
|
|
34
|
+
id?: string;
|
|
35
|
+
name: string;
|
|
36
|
+
url: string;
|
|
37
|
+
tracks: number;
|
|
38
|
+
nowPlaying: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_NAME = 60;
|
|
42
|
+
const MAX_TRACK = 120;
|
|
43
|
+
|
|
44
|
+
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
45
|
+
export function clean(value: unknown, max: number): string {
|
|
46
|
+
if (typeof value !== "string") return "";
|
|
47
|
+
// Control characters include the escape that starts an ANSI sequence, and
|
|
48
|
+
// this text is rendered in a terminal as well as a browser.
|
|
49
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A URL we are willing to list. It has to be somewhere a browser can go, and
|
|
54
|
+
* it must not be a loopback or link-local address: those are only reachable
|
|
55
|
+
* from the machine that published them, so listing one is an entry nobody but
|
|
56
|
+
* the publisher can ever open.
|
|
57
|
+
*/
|
|
58
|
+
export function publishable(raw: string): URL | null {
|
|
59
|
+
let url: URL;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(raw);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
66
|
+
|
|
67
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
68
|
+
if (host === "localhost" || host === "::1" || host.endsWith(".localhost")) return null;
|
|
69
|
+
if (/^127\./.test(host) || /^169\.254\./.test(host)) return null;
|
|
70
|
+
return url;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseAnnouncement(input: unknown): Announcement | null {
|
|
74
|
+
if (typeof input !== "object" || input === null) return null;
|
|
75
|
+
const record = input as Record<string, unknown>;
|
|
76
|
+
|
|
77
|
+
const url = typeof record["url"] === "string" ? record["url"] : "";
|
|
78
|
+
if (publishable(url) === null) return null;
|
|
79
|
+
|
|
80
|
+
const name = clean(record["name"], MAX_NAME);
|
|
81
|
+
const tracks = Number(record["tracks"]);
|
|
82
|
+
return {
|
|
83
|
+
...(typeof record["id"] === "string" ? { id: clean(record["id"], 40) } : {}),
|
|
84
|
+
name: name || "a nixamp",
|
|
85
|
+
url,
|
|
86
|
+
tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
|
|
87
|
+
nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The registry. In memory on purpose: see the note at the top of the file.
|
|
93
|
+
* One entry per URL, so a publisher restarting does not leave a ghost of
|
|
94
|
+
* itself behind next to the entry that replaced it.
|
|
95
|
+
*/
|
|
96
|
+
export class Directory {
|
|
97
|
+
private readonly items = new Map<string, Listing>();
|
|
98
|
+
private sequence = 0;
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
private readonly ttl = TTL_MS,
|
|
102
|
+
private readonly now: () => number = Date.now,
|
|
103
|
+
) {}
|
|
104
|
+
|
|
105
|
+
announce(announcement: Announcement): Listing {
|
|
106
|
+
this.sweep();
|
|
107
|
+
const existing = [...this.items.values()].find((item) => item.url === announcement.url);
|
|
108
|
+
const id = existing?.id ?? `s${++this.sequence}${this.now().toString(36)}`;
|
|
109
|
+
const listing: Listing = {
|
|
110
|
+
id,
|
|
111
|
+
name: announcement.name,
|
|
112
|
+
url: announcement.url,
|
|
113
|
+
tracks: announcement.tracks,
|
|
114
|
+
nowPlaying: announcement.nowPlaying,
|
|
115
|
+
updatedAt: this.now(),
|
|
116
|
+
};
|
|
117
|
+
this.items.set(id, listing);
|
|
118
|
+
return listing;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
withdraw(id: string): void {
|
|
122
|
+
this.items.delete(id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
list(): Listing[] {
|
|
126
|
+
this.sweep();
|
|
127
|
+
return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Forget anything that stopped renewing. */
|
|
131
|
+
private sweep(): void {
|
|
132
|
+
const cutoff = this.now() - this.ttl;
|
|
133
|
+
for (const [id, item] of this.items) if (item.updatedAt < cutoff) this.items.delete(id);
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/ingest.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming into a nixamp, rather than out of one.
|
|
3
|
+
*
|
|
4
|
+
* A phone or a laptop sends live audio to the server, and the server plays it,
|
|
5
|
+
* serves it to listeners, and broadcasts it onward. Two ways in, because one is
|
|
6
|
+
* not enough:
|
|
7
|
+
*
|
|
8
|
+
* - A single long POST whose body is the stream. ffmpeg, OBS, curl and the
|
|
9
|
+
* desktop app can all do this, and it is the efficient one.
|
|
10
|
+
* - A sequence of small POSTs, for browsers. Chrome only allows a streaming
|
|
11
|
+
* request body over HTTP/2, and a nixamp on your own network is plain
|
|
12
|
+
* HTTP/1.1, so a phone that wants to broadcast has to send chunks.
|
|
13
|
+
*
|
|
14
|
+
* Both end up writing into the same ffmpeg stdin, which is what makes the two
|
|
15
|
+
* paths interchangeable to everything downstream.
|
|
16
|
+
*
|
|
17
|
+
* And a third, which is the one anything native should use: RTMP. Point OBS,
|
|
18
|
+
* Larix or another ffmpeg at rtmp://this-machine/live/<key> and it publishes,
|
|
19
|
+
* with no nixamp-shaped client in the middle. ffmpeg listens for it (-rtmp_listen),
|
|
20
|
+
* so this costs no dependency and no protocol implementation. The HTTP paths
|
|
21
|
+
* above are not a workaround for that: they are what a *browser* has, since a
|
|
22
|
+
* web page cannot speak RTMP at all.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
import type { Readable } from "node:stream";
|
|
27
|
+
|
|
28
|
+
/** A live source is one session at a time: two would be two songs at once. */
|
|
29
|
+
export interface IngestSession {
|
|
30
|
+
id: string;
|
|
31
|
+
/** What the sender called itself. */
|
|
32
|
+
name: string;
|
|
33
|
+
/** The container the sender is producing, e.g. webm from MediaRecorder. */
|
|
34
|
+
format: string;
|
|
35
|
+
startedAt: number;
|
|
36
|
+
bytes: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IngestStatus {
|
|
40
|
+
live: boolean;
|
|
41
|
+
session: IngestSession | null;
|
|
42
|
+
/** Where a native broadcaster should publish, when one is being listened for. */
|
|
43
|
+
rtmp: { port: number; path: string } | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Containers a browser or a desktop encoder actually produces. */
|
|
47
|
+
const FORMATS = new Set(["webm", "ogg", "mp4", "matroska", "mp3", "wav", "flv"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* ffmpeg's `-f` is a demuxer name, and passing an unknown one is how a stream
|
|
51
|
+
* dies four seconds in with a message nobody sees. An unrecognised container is
|
|
52
|
+
* refused up front instead.
|
|
53
|
+
*/
|
|
54
|
+
export function normaliseFormat(value: unknown): string | null {
|
|
55
|
+
if (typeof value !== "string") return null;
|
|
56
|
+
const format = value.trim().toLowerCase();
|
|
57
|
+
if (format === "") return null;
|
|
58
|
+
// A MediaRecorder mime type: audio/webm;codecs=opus.
|
|
59
|
+
// The subtype may carry a hyphen: audio/x-matroska is what some browsers
|
|
60
|
+
// hand out, and stopping at the hyphen turned it into "x".
|
|
61
|
+
const fromMime = /^(?:audio|video)\/([a-z0-9-]+)/.exec(format)?.[1];
|
|
62
|
+
const candidate = fromMime ?? format;
|
|
63
|
+
const mapped = candidate === "x-matroska" ? "matroska" : candidate;
|
|
64
|
+
return FORMATS.has(mapped) ? mapped : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface IngestOptions {
|
|
68
|
+
ffmpeg: string[];
|
|
69
|
+
/** Where the decoded audio should go: a file ffmpeg writes, or a pipe. */
|
|
70
|
+
sink: string;
|
|
71
|
+
/** Called when a session starts, so the player can switch to it. */
|
|
72
|
+
onStart: (session: IngestSession) => void;
|
|
73
|
+
/** Called when it ends, cleanly or otherwise. */
|
|
74
|
+
onEnd: (session: IngestSession, error: string) => void;
|
|
75
|
+
/** Encoded audio, as it arrives from a live publisher. */
|
|
76
|
+
onAudio?: (chunk: Buffer) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The live input.
|
|
81
|
+
*
|
|
82
|
+
* One session at a time: a second sender is refused rather than mixed, because
|
|
83
|
+
* mixing two uninvited streams is never what anybody meant.
|
|
84
|
+
*/
|
|
85
|
+
export class Ingest {
|
|
86
|
+
private child: ChildProcess | null = null;
|
|
87
|
+
private session: IngestSession | null = null;
|
|
88
|
+
private closing = false;
|
|
89
|
+
/** The RTMP listener, which outlives any one publisher. */
|
|
90
|
+
private rtmpChild: ChildProcess | null = null;
|
|
91
|
+
private rtmpPort: number | null = null;
|
|
92
|
+
private rtmpKey = "";
|
|
93
|
+
|
|
94
|
+
constructor(private readonly options: IngestOptions) {}
|
|
95
|
+
|
|
96
|
+
status(): IngestStatus {
|
|
97
|
+
return {
|
|
98
|
+
live: this.session !== null,
|
|
99
|
+
session: this.session,
|
|
100
|
+
rtmp: this.rtmpPort === null ? null : { port: this.rtmpPort, path: `/live/${this.rtmpKey}` },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get live(): boolean {
|
|
105
|
+
return this.session !== null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Open a session. Returns the session, or null when one is already running:
|
|
110
|
+
* the caller answers 409, because "somebody else is already broadcasting" is
|
|
111
|
+
* a different problem from "your request was wrong".
|
|
112
|
+
*/
|
|
113
|
+
open(name: string, format: string): IngestSession | null {
|
|
114
|
+
if (this.session !== null) return null;
|
|
115
|
+
|
|
116
|
+
const session: IngestSession = {
|
|
117
|
+
id: randomBytes(8).toString("hex"),
|
|
118
|
+
name: name || "a device",
|
|
119
|
+
format,
|
|
120
|
+
startedAt: Date.now(),
|
|
121
|
+
bytes: 0,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
125
|
+
const child = spawn(
|
|
126
|
+
command,
|
|
127
|
+
[
|
|
128
|
+
...prefix,
|
|
129
|
+
"-hide_banner",
|
|
130
|
+
"-loglevel", "error",
|
|
131
|
+
// The demuxer is stated because ffmpeg mis-probes a live, unseekable
|
|
132
|
+
// pipe: it reads a few kilobytes, guesses, and guesses wrong.
|
|
133
|
+
"-f", format,
|
|
134
|
+
"-i", "pipe:0",
|
|
135
|
+
"-vn",
|
|
136
|
+
"-c:a", "libmp3lame",
|
|
137
|
+
"-b:a", "192k",
|
|
138
|
+
"-y",
|
|
139
|
+
"-f", "mp3",
|
|
140
|
+
this.options.sink,
|
|
141
|
+
],
|
|
142
|
+
{ stdio: ["pipe", "ignore", "pipe"] },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
let tail = "";
|
|
146
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
147
|
+
tail = (tail + chunk.toString()).slice(-2000);
|
|
148
|
+
});
|
|
149
|
+
// A sender that hangs up mid-write breaks the pipe, and an unhandled EPIPE
|
|
150
|
+
// takes the whole server down with it.
|
|
151
|
+
child.stdin?.on("error", () => this.close(""));
|
|
152
|
+
child.on("error", (error) => this.close(error.message));
|
|
153
|
+
child.on("close", (code) => this.close(code === 0 ? "" : tail.trim().split("\n").pop() ?? ""));
|
|
154
|
+
|
|
155
|
+
this.child = child;
|
|
156
|
+
this.session = session;
|
|
157
|
+
this.closing = false;
|
|
158
|
+
this.options.onStart(session);
|
|
159
|
+
return session;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Feed it. Returns false once the session is over. */
|
|
163
|
+
write(chunk: Buffer): boolean {
|
|
164
|
+
const child = this.child;
|
|
165
|
+
const session = this.session;
|
|
166
|
+
if (child === null || session === null || child.stdin === null) return false;
|
|
167
|
+
session.bytes += chunk.byteLength;
|
|
168
|
+
return child.stdin.write(chunk);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Pipe a whole request body in, for a sender that can stream one. */
|
|
172
|
+
async pump(body: Readable): Promise<void> {
|
|
173
|
+
for await (const chunk of body) {
|
|
174
|
+
if (!this.write(chunk as Buffer)) {
|
|
175
|
+
// Backpressure: wait for the drain rather than growing a buffer that
|
|
176
|
+
// is really the network's problem.
|
|
177
|
+
await new Promise((done) => this.child?.stdin?.once("drain", done) ?? done(null));
|
|
178
|
+
}
|
|
179
|
+
if (this.session === null) return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Wait for an RTMP publisher, and keep waiting after each one leaves.
|
|
185
|
+
*
|
|
186
|
+
* ffmpeg is the RTMP server here: `-rtmp_listen 1` binds the port and blocks
|
|
187
|
+
* until somebody publishes. It serves one publisher and exits, so the
|
|
188
|
+
* listener is started again afterwards -- otherwise a broadcaster who
|
|
189
|
+
* reconnects finds nothing listening.
|
|
190
|
+
*/
|
|
191
|
+
listenRtmp(port: number, key: string): void {
|
|
192
|
+
this.rtmpPort = port;
|
|
193
|
+
this.rtmpKey = key;
|
|
194
|
+
this.armRtmp();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Stop waiting for publishers. */
|
|
198
|
+
stopRtmp(): void {
|
|
199
|
+
this.rtmpPort = null;
|
|
200
|
+
const listener = this.rtmpChild;
|
|
201
|
+
this.rtmpChild = null;
|
|
202
|
+
listener?.kill("SIGKILL");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private armRtmp(): void {
|
|
206
|
+
const port = this.rtmpPort;
|
|
207
|
+
if (port === null || this.rtmpChild !== null) return;
|
|
208
|
+
|
|
209
|
+
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
210
|
+
const child = spawn(
|
|
211
|
+
command,
|
|
212
|
+
[
|
|
213
|
+
...prefix,
|
|
214
|
+
"-hide_banner",
|
|
215
|
+
"-loglevel", "error",
|
|
216
|
+
"-rtmp_listen", "1",
|
|
217
|
+
// Wait indefinitely: a stream that starts tomorrow is still the stream.
|
|
218
|
+
"-timeout", "-1",
|
|
219
|
+
"-f", "flv",
|
|
220
|
+
"-i", `rtmp://0.0.0.0:${port}/live/${this.rtmpKey}`,
|
|
221
|
+
"-vn",
|
|
222
|
+
"-c:a", "libmp3lame",
|
|
223
|
+
"-b:a", "192k",
|
|
224
|
+
"-y",
|
|
225
|
+
"-f", "mp3",
|
|
226
|
+
this.options.sink,
|
|
227
|
+
],
|
|
228
|
+
// stdout is watched rather than ignored: the encoded bytes are the only
|
|
229
|
+
// honest signal that a publisher turned up. ffmpeg does not announce a
|
|
230
|
+
// connect, a healthy stream says nothing at -loglevel error, and
|
|
231
|
+
// -progress only reports at the end in this build. Audio existing means
|
|
232
|
+
// audio arrived.
|
|
233
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const session: IngestSession = {
|
|
237
|
+
id: randomBytes(8).toString("hex"),
|
|
238
|
+
name: "an RTMP publisher",
|
|
239
|
+
format: "flv",
|
|
240
|
+
startedAt: Date.now(),
|
|
241
|
+
bytes: 0,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
let tail = "";
|
|
245
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
246
|
+
tail = (tail + chunk.toString()).slice(-2000);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
250
|
+
if (this.rtmpChild !== child) return;
|
|
251
|
+
if (this.session === null) {
|
|
252
|
+
this.session = session;
|
|
253
|
+
this.options.onStart(session);
|
|
254
|
+
}
|
|
255
|
+
if (this.session === session) session.bytes += chunk.byteLength;
|
|
256
|
+
// Consumed and dropped for now. The pipe has to be read either way --
|
|
257
|
+
// an unread one fills and stalls the encoder -- and handing these bytes
|
|
258
|
+
// to the listeners is the next piece of work, not a missing one here.
|
|
259
|
+
this.options.onAudio?.(chunk);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
child.on("error", () => {
|
|
263
|
+
if (this.rtmpChild === child) this.rtmpChild = null;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
child.on("close", () => {
|
|
267
|
+
if (this.rtmpChild !== child) return;
|
|
268
|
+
this.rtmpChild = null;
|
|
269
|
+
if (this.session === session) {
|
|
270
|
+
this.session = null;
|
|
271
|
+
// A publisher disconnecting is how a broadcast ends, not an error.
|
|
272
|
+
this.options.onEnd(session, "");
|
|
273
|
+
}
|
|
274
|
+
// Listen again for the next one.
|
|
275
|
+
if (this.rtmpPort !== null) this.armRtmp();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
this.rtmpChild = child;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** End the session, whoever ended it. */
|
|
282
|
+
close(error = ""): void {
|
|
283
|
+
if (this.closing) return;
|
|
284
|
+
this.closing = true;
|
|
285
|
+
const session = this.session;
|
|
286
|
+
const child = this.child;
|
|
287
|
+
this.session = null;
|
|
288
|
+
this.child = null;
|
|
289
|
+
try {
|
|
290
|
+
child?.stdin?.end();
|
|
291
|
+
} catch {
|
|
292
|
+
// Already broken, which is usually why we are here.
|
|
293
|
+
}
|
|
294
|
+
child?.kill("SIGKILL");
|
|
295
|
+
if (session) this.options.onEnd(session, error);
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* ffmpeg decodes; we read every sample on its way to the speakers and draw it.
|
|
8
8
|
*/
|
|
9
9
|
import { createApp, themes, type BrailleCanvas, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
10
11
|
import { resolve } from "node:path";
|
|
11
12
|
import {
|
|
12
13
|
detectTools, formatTime, peaks, RATE, Stream, toMono,
|
|
@@ -14,7 +15,8 @@ import {
|
|
|
14
15
|
} from "./audio.ts";
|
|
15
16
|
import { Analyser, bandEdges, bands, decay } from "./fft.ts";
|
|
16
17
|
import { version } from "./meta.ts";
|
|
17
|
-
import { displayName,
|
|
18
|
+
import { displayName, loadSource } from "./playlist.ts";
|
|
19
|
+
import { isRemote } from "./sources.ts";
|
|
18
20
|
import { DEFAULT_PORT } from "./server.ts";
|
|
19
21
|
|
|
20
22
|
const FFT_SIZE = 2048;
|
|
@@ -64,21 +66,98 @@ export function barGlyph(value: number): string {
|
|
|
64
66
|
|
|
65
67
|
const HELP = `nixamp — it really whips the terminal's ass.
|
|
66
68
|
|
|
67
|
-
nixamp [
|
|
68
|
-
nixamp serve [
|
|
69
|
+
nixamp [source] play it in the terminal
|
|
70
|
+
nixamp serve [source] [options] play here, and hand out a browser remote
|
|
71
|
+
nixamp daemon start|stop|status serve in the background, and let go of it
|
|
72
|
+
nixamp admin [--url U] [--key K] who is connected, and re-stream to them
|
|
73
|
+
nixamp login [--signup] sign in to nixamp.com
|
|
74
|
+
nixamp logout / whoami forget it, or check it
|
|
69
75
|
nixamp update [version] re-run the installer, keeping your choices
|
|
70
76
|
nixamp uninstall [--yes] remove everything the installer created
|
|
71
77
|
|
|
78
|
+
A source is a directory, a file, an .m3u, an .m3u8, a .pls, or a URL to any
|
|
79
|
+
of those.
|
|
80
|
+
|
|
72
81
|
Options for serve:
|
|
73
82
|
-p, --port N port to listen on (default ${DEFAULT_PORT})
|
|
74
|
-
-h, --host HOST address to bind (default
|
|
83
|
+
-h, --host HOST address to bind (default 0.0.0.0, every interface)
|
|
75
84
|
--web DIR directory of built PWA files to serve at /
|
|
76
85
|
--no-media do not stream the library's bytes to remotes
|
|
86
|
+
--no-key serve to anyone who can reach the port, with no share link
|
|
87
|
+
--open-port let the port through the local firewall, and close it on exit
|
|
88
|
+
--publish list it at nixamp.com/directory without asking first
|
|
89
|
+
--no-publish never list it, and do not ask
|
|
90
|
+
--name NAME what to call it in the directory (default: this hostname)
|
|
91
|
+
--ingest accept a live stream in at POST /api/ingest
|
|
92
|
+
--rtmp-in N also listen for RTMP publishers (OBS, Larix) from port N
|
|
93
|
+
--rtmp-streams N how many may publish at once (default 3, a port each)
|
|
94
|
+
--rtmp D broadcast out, e.g. --rtmp youtube=<key>. Repeatable
|
|
95
|
+
--x402 charge for listening once more than 5 people are listening
|
|
96
|
+
--no-x402 never charge
|
|
77
97
|
|
|
78
98
|
-v, --version print the version
|
|
79
99
|
--help print this
|
|
80
100
|
`;
|
|
81
101
|
|
|
102
|
+
/**
|
|
103
|
+
* `nixamp daemon <start|stop|status>`.
|
|
104
|
+
*
|
|
105
|
+
* The daemon is `nixamp serve` with nobody holding its terminal, so this is
|
|
106
|
+
* mostly bookkeeping: start it detached, remember where it went, and be able
|
|
107
|
+
* to answer whether it is still there.
|
|
108
|
+
*/
|
|
109
|
+
async function runDaemon(argv: string[]): Promise<number> {
|
|
110
|
+
const d = await import("./daemon.ts");
|
|
111
|
+
const [action = "status", ...rest] = argv;
|
|
112
|
+
const entry = fileURLToPath(new URL("./main.js", import.meta.url));
|
|
113
|
+
|
|
114
|
+
if (action === "start") {
|
|
115
|
+
try {
|
|
116
|
+
const state = await d.start(rest, entry);
|
|
117
|
+
console.log(`nixamp daemon running (pid ${state.pid})`);
|
|
118
|
+
const url = d.daemonUrl(state);
|
|
119
|
+
console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
|
|
120
|
+
console.log(` ${state.source}`);
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log(" nixamp admin who is connected");
|
|
123
|
+
console.log(" nixamp daemon stop when you are done");
|
|
124
|
+
return 0;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error((error as Error).message);
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (action === "stop") {
|
|
132
|
+
const stopped = await d.stop();
|
|
133
|
+
console.log(stopped ? "nixamp daemon stopped" : "nixamp: no daemon was running");
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (action === "status") {
|
|
138
|
+
const { running, state } = d.status();
|
|
139
|
+
if (!state) {
|
|
140
|
+
console.log("nixamp: no daemon. Start one with `nixamp daemon start`.");
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
// A pid file outlives its process often enough that saying "running"
|
|
144
|
+
// without checking is how you report a daemon that died on Tuesday.
|
|
145
|
+
if (!running) {
|
|
146
|
+
console.log(`nixamp: the daemon (pid ${state.pid}) is gone. See ${state.log}`);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
const url = d.daemonUrl(state);
|
|
150
|
+
console.log(`nixamp daemon running (pid ${state.pid})`);
|
|
151
|
+
console.log(` ${state.key ? `${url}/s/${state.key}` : url}`);
|
|
152
|
+
console.log(` ${state.source}`);
|
|
153
|
+
console.log(` up ${Math.round((Date.now() - state.startedAt) / 1000)}s`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.error(`nixamp daemon: unknown action ${action}. Try start, stop or status.`);
|
|
158
|
+
return 64;
|
|
159
|
+
}
|
|
160
|
+
|
|
82
161
|
/**
|
|
83
162
|
* The whole CLI, as a function. `bin/nixamp.mjs` imports and calls it: relying
|
|
84
163
|
* on `import.meta.main` there would leave the installed binary doing nothing,
|
|
@@ -92,6 +171,25 @@ export async function main(): Promise<void> {
|
|
|
92
171
|
await serve(rest, version());
|
|
93
172
|
return;
|
|
94
173
|
}
|
|
174
|
+
if (first === "daemon") {
|
|
175
|
+
process.exitCode = await runDaemon(rest);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (first === "admin") {
|
|
179
|
+
const { admin } = await import("./admin.ts");
|
|
180
|
+
await admin(rest);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (first === "login" || first === "signup") {
|
|
184
|
+
const { login } = await import("./session.ts");
|
|
185
|
+
process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (first === "logout" || first === "whoami") {
|
|
189
|
+
const session = await import("./session.ts");
|
|
190
|
+
process.exitCode = first === "logout" ? session.logout() : await session.whoami();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
95
193
|
if (first === "update" || first === "uninstall") {
|
|
96
194
|
const manage = await import("./manage.ts");
|
|
97
195
|
process.exitCode = first === "update" ? manage.update(rest) : manage.uninstall(rest);
|
|
@@ -100,9 +198,12 @@ export async function main(): Promise<void> {
|
|
|
100
198
|
if (first === "--version" || first === "-v") { console.log(version()); return; }
|
|
101
199
|
if (first === "--help") { console.log(HELP); return; }
|
|
102
200
|
|
|
103
|
-
|
|
201
|
+
// resolve() would turn https://host/x into /cwd/https:/host/x, so a URL is
|
|
202
|
+
// left exactly as it was typed.
|
|
203
|
+
const asked = first ?? ".";
|
|
204
|
+
const target = isRemote(asked) ? asked : resolve(asked);
|
|
104
205
|
const tools = detectTools();
|
|
105
|
-
const tracks =
|
|
206
|
+
const tracks = await loadSource(tools, target);
|
|
106
207
|
if (tracks.length === 0) {
|
|
107
208
|
console.error(`nixamp: no audio files under ${target}`);
|
|
108
209
|
process.exit(1);
|
package/src/manage.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
12
13
|
|
|
13
14
|
/** What the installer recorded about this install. */
|
|
14
15
|
export interface Manifest {
|
|
@@ -23,12 +24,15 @@ export interface Manifest {
|
|
|
23
24
|
|
|
24
25
|
const SITE = "https://nixamp.com";
|
|
25
26
|
|
|
27
|
+
/** Windows has its own installer, its own shim and its own removal script. */
|
|
28
|
+
const windows = process.platform === "win32";
|
|
29
|
+
|
|
26
30
|
/**
|
|
27
31
|
* Where the installer put things. `NIXAMP_HOME` is exported by the shim it
|
|
28
32
|
* wrote, which is the only thing that knows for certain; the walk up from this
|
|
29
33
|
* file covers a shim from an older install that did not set it.
|
|
30
34
|
*/
|
|
31
|
-
export function installRoot(from = new URL(".", import.meta.url)
|
|
35
|
+
export function installRoot(from = fileURLToPath(new URL(".", import.meta.url))): string | null {
|
|
32
36
|
const declared = process.env["NIXAMP_HOME"];
|
|
33
37
|
if (declared && existsSync(join(declared, "manifest.json"))) return declared;
|
|
34
38
|
|
|
@@ -64,7 +68,11 @@ function notInstalled(what: string): number {
|
|
|
64
68
|
console.error("");
|
|
65
69
|
console.error(" Installed with npm or bun: npm uninstall -g nixamp");
|
|
66
70
|
console.error(" Running from a checkout: delete the checkout");
|
|
67
|
-
console.error(
|
|
71
|
+
console.error(
|
|
72
|
+
windows
|
|
73
|
+
? ` Wanted the installed one: irm ${SITE}/install.ps1 | iex`
|
|
74
|
+
: ` Wanted the installed one: curl -fsSL ${SITE}/install.sh | sh`,
|
|
75
|
+
);
|
|
68
76
|
return 69;
|
|
69
77
|
}
|
|
70
78
|
|
|
@@ -78,13 +86,26 @@ export function update(argv: string[]): number {
|
|
|
78
86
|
const manifest = root ? readManifest(root) : null;
|
|
79
87
|
if (!root || !manifest) return notInstalled("update");
|
|
80
88
|
|
|
81
|
-
const installer = manifest.installer || `${SITE}
|
|
82
|
-
const args = ["-s", "--", manifest.desktop ? "--desktop" : "--cli-only", "--prefix", manifest.prefix];
|
|
89
|
+
const installer = manifest.installer || `${SITE}/${windows ? "install.ps1" : "install.sh"}`;
|
|
83
90
|
const wanted = argv.find((a) => !a.startsWith("-"));
|
|
84
|
-
if (wanted) args.push("--version", wanted);
|
|
85
91
|
|
|
86
92
|
console.log(`nixamp ${manifest.version} is installed. Fetching the installer...`);
|
|
87
93
|
|
|
94
|
+
if (windows) {
|
|
95
|
+
// PowerShell fetches and runs it in one expression, which is also the
|
|
96
|
+
// documented install line, so an update takes a fresh install's path.
|
|
97
|
+
const flags = [manifest.desktop ? "" : "-CliOnly", "-Prefix", quote(manifest.prefix)];
|
|
98
|
+
if (wanted) flags.push("-Version", quote(wanted));
|
|
99
|
+
const expression = `& ([scriptblock]::Create((irm ${installer}))) ${flags.filter(Boolean).join(" ")}`;
|
|
100
|
+
const run = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", expression], {
|
|
101
|
+
stdio: "inherit",
|
|
102
|
+
});
|
|
103
|
+
return run.status ?? 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const args = ["-s", "--", manifest.desktop ? "--desktop" : "--cli-only", "--prefix", manifest.prefix];
|
|
107
|
+
if (wanted) args.push("--version", wanted);
|
|
108
|
+
|
|
88
109
|
const fetcher = which("curl") ? ["curl", "-fsSL", installer] : which("wget") ? ["wget", "-qO-", installer] : null;
|
|
89
110
|
if (!fetcher) {
|
|
90
111
|
console.error("nixamp: curl or wget is required to update.");
|
|
@@ -118,17 +139,24 @@ export function uninstall(argv: string[]): number {
|
|
|
118
139
|
return 0;
|
|
119
140
|
}
|
|
120
141
|
|
|
121
|
-
const script = join(root, "uninstall.sh");
|
|
142
|
+
const script = join(root, windows ? "uninstall.ps1" : "uninstall.sh");
|
|
122
143
|
if (!existsSync(script)) {
|
|
123
144
|
console.error(`nixamp: ${script} is missing, so removal cannot be exact.`);
|
|
124
145
|
console.error(` The manifest lists: ${manifest.paths.join(", ")}`);
|
|
125
146
|
return 1;
|
|
126
147
|
}
|
|
127
148
|
|
|
128
|
-
const run =
|
|
149
|
+
const run = windows
|
|
150
|
+
? spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script], { stdio: "inherit" })
|
|
151
|
+
: spawnSync("sh", [script], { stdio: "inherit" });
|
|
129
152
|
return run.status ?? 1;
|
|
130
153
|
}
|
|
131
154
|
|
|
155
|
+
/** A PowerShell single-quoted string: the only escape inside one is a doubled quote. */
|
|
156
|
+
function quote(value: string): string {
|
|
157
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
158
|
+
}
|
|
159
|
+
|
|
132
160
|
function which(command: string): boolean {
|
|
133
161
|
return spawnSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" }).status === 0;
|
|
134
162
|
}
|