nixamp 0.2.0 → 0.4.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 +171 -0
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -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 +6 -0
- package/dist/connections.js +13 -0
- package/dist/directory.d.ts +186 -0
- package/dist/directory.js +275 -0
- package/dist/durable.d.ts +70 -0
- package/dist/durable.js +156 -0
- package/dist/follows.d.ts +92 -0
- package/dist/follows.js +248 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +21 -0
- package/dist/manage.js +2 -1
- package/dist/notify.d.ts +83 -0
- package/dist/notify.js +126 -0
- package/dist/optin.d.ts +37 -0
- package/dist/optin.js +122 -0
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/partyline.d.ts +259 -0
- package/dist/partyline.js +616 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/playlist.js +5 -0
- package/dist/publish.d.ts +57 -0
- package/dist/publish.js +106 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +94 -0
- package/dist/server.js +1158 -12
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +26 -0
- package/dist/share.js +31 -0
- package/package.json +8 -2
- package/src/accounts.ts +193 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +13 -0
- package/src/directory.ts +362 -0
- package/src/durable.ts +215 -0
- package/src/follows.ts +307 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/notify.ts +217 -0
- package/src/optin.ts +128 -0
- package/src/owner.ts +113 -0
- package/src/partyline.ts +742 -0
- package/src/paywall.ts +198 -0
- package/src/playlist.ts +5 -0
- package/src/publish.ts +137 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +1304 -12
- package/src/session.ts +209 -0
- package/src/share.ts +40 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
- package/web/dist/assets/index-qRguFskX.js +1 -0
- package/web/dist/index.html +62 -6
- package/web/dist/install.sh +82 -0
- package/web/dist/sw.js +45 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/dist/paywall.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paying to listen, once a stream is busy.
|
|
3
|
+
*
|
|
4
|
+
* A nixamp serving a handful of friends should cost nothing and ask nothing.
|
|
5
|
+
* Past that it is bandwidth someone is paying for, so the gate opens: over the
|
|
6
|
+
* free listener count, a new listener is answered 402 with an x402 offer and a
|
|
7
|
+
* dollar buys a day.
|
|
8
|
+
*
|
|
9
|
+
* Three things are deliberate. The count is of *live* listeners, so a stream
|
|
10
|
+
* quietens back to free on its own. Only the audio is gated, because a 402 on
|
|
11
|
+
* /api/state would break the page that has to render the offer. And nobody is
|
|
12
|
+
* ever cut off mid-track: the gate is asked once, when a request arrives, and
|
|
13
|
+
* a listener who got in stays in.
|
|
14
|
+
*/
|
|
15
|
+
import { createGateway } from "@profullstack/x402-gateway";
|
|
16
|
+
/** Listeners who get in for nothing. The sixth is the one who pays. */
|
|
17
|
+
export const FREE_LISTENERS = 5;
|
|
18
|
+
export const DEFAULT_PAYWALL = {
|
|
19
|
+
enabled: false,
|
|
20
|
+
payTo: "",
|
|
21
|
+
coinpayKey: "",
|
|
22
|
+
priceCents: 100,
|
|
23
|
+
passMinutes: 1440,
|
|
24
|
+
};
|
|
25
|
+
/** Only the audio is behind the gate. */
|
|
26
|
+
export const GATED = ["/api/stream/", "/api/media/", "/api/live"];
|
|
27
|
+
export function isGated(path) {
|
|
28
|
+
return GATED.some((prefix) => path.startsWith(prefix));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether this request should be charged at all. Configuration first, because
|
|
32
|
+
* a disabled paywall must never answer 402; then the path, then how busy the
|
|
33
|
+
* stream is.
|
|
34
|
+
*/
|
|
35
|
+
export function shouldCharge(config, path, liveListeners) {
|
|
36
|
+
if (!config.enabled || !config.payTo)
|
|
37
|
+
return false;
|
|
38
|
+
if (!isGated(path))
|
|
39
|
+
return false;
|
|
40
|
+
return liveListeners > FREE_LISTENERS;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A node http shim over the gateway's Fetch-API handler. Returns true when it
|
|
44
|
+
* answered the request, so the server's own routing can stop.
|
|
45
|
+
*/
|
|
46
|
+
export function createPaywall(options) {
|
|
47
|
+
let built = null;
|
|
48
|
+
/** Rebuilt when the configuration changes, because it can change at runtime. */
|
|
49
|
+
const gateway = (config, siteUrl) => {
|
|
50
|
+
const key = `${siteUrl} ${JSON.stringify(config)}`;
|
|
51
|
+
if (built?.key === key)
|
|
52
|
+
return built.handle;
|
|
53
|
+
const gate = createGateway({
|
|
54
|
+
siteUrl,
|
|
55
|
+
siteName: "nixamp",
|
|
56
|
+
payTo: config.payTo,
|
|
57
|
+
priceCents: config.priceCents,
|
|
58
|
+
passMinutes: config.passMinutes,
|
|
59
|
+
coinpay: { apiKey: config.coinpayKey },
|
|
60
|
+
header: "x-nixamp-pass",
|
|
61
|
+
path: "/listen/pay",
|
|
62
|
+
// Whether to charge is about how busy this stream is, not about who is
|
|
63
|
+
// asking, so the agent lists play no part.
|
|
64
|
+
isPaidAgent: () => true,
|
|
65
|
+
benefits: ["Listen to this stream for a day, as many tracks as you like."],
|
|
66
|
+
});
|
|
67
|
+
built = { key, handle: (request) => gate.handle(request) };
|
|
68
|
+
return built.handle;
|
|
69
|
+
};
|
|
70
|
+
return async function paywall(request, response, path) {
|
|
71
|
+
const config = options.config();
|
|
72
|
+
// The sales page answers whenever the paywall is configured, so a listener
|
|
73
|
+
// can buy a pass before the stream is busy enough to need one.
|
|
74
|
+
const selling = config.enabled && config.payTo !== "" && path.startsWith("/listen/pay");
|
|
75
|
+
if (!selling && !shouldCharge(config, path, options.liveListeners()))
|
|
76
|
+
return false;
|
|
77
|
+
if (options.exempt(request))
|
|
78
|
+
return false;
|
|
79
|
+
const siteUrl = options.siteUrl();
|
|
80
|
+
const answer = await gateway(config, siteUrl)(toRequest(request, siteUrl));
|
|
81
|
+
if (answer === null)
|
|
82
|
+
return false;
|
|
83
|
+
const body = await rewrite(answer, siteUrl);
|
|
84
|
+
response.writeHead(answer.status, {
|
|
85
|
+
...Object.fromEntries(answer.headers),
|
|
86
|
+
"content-length": String(body.byteLength),
|
|
87
|
+
});
|
|
88
|
+
response.end(body);
|
|
89
|
+
return true;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The gateway sells crawl access, and says so: its 402 tells the caller that
|
|
94
|
+
* "payment is required for training crawlers". Someone trying to hear a song
|
|
95
|
+
* is not a crawler, so the sentence is replaced on the way out. The offer
|
|
96
|
+
* itself is untouched -- only the words are ours.
|
|
97
|
+
*/
|
|
98
|
+
export async function rewrite(answer, siteUrl) {
|
|
99
|
+
const raw = Buffer.from(await answer.arrayBuffer());
|
|
100
|
+
if (answer.status !== 402 || !(answer.headers.get("content-type") ?? "").includes("json"))
|
|
101
|
+
return raw;
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(raw.toString("utf8"));
|
|
104
|
+
parsed["error"] =
|
|
105
|
+
`This stream has more than ${FREE_LISTENERS} people listening. ` +
|
|
106
|
+
`A pass is ${parsed.pass?.price ?? "$1"} for a day: ${siteUrl}/listen/pay`;
|
|
107
|
+
return Buffer.from(JSON.stringify(parsed));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Not JSON after all: send exactly what the gateway produced.
|
|
111
|
+
return raw;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Enough of a Fetch Request for the gateway: it reads the URL, the method and
|
|
116
|
+
* headers. A body would need streaming, and nothing it gates has one.
|
|
117
|
+
*/
|
|
118
|
+
export function toRequest(request, siteUrl) {
|
|
119
|
+
const headers = new Headers();
|
|
120
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
121
|
+
if (value === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
124
|
+
}
|
|
125
|
+
return new Request(new URL(request.url ?? "/", siteUrl), {
|
|
126
|
+
method: request.method ?? "GET",
|
|
127
|
+
headers,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Read a paywall out of the environment, for an operator who runs one by hand. */
|
|
131
|
+
export function paywallFromEnv(env = process.env) {
|
|
132
|
+
const price = Number(env["NIXAMP_PRICE_CENTS"]);
|
|
133
|
+
const minutes = Number(env["NIXAMP_PASS_MINUTES"]);
|
|
134
|
+
return {
|
|
135
|
+
enabled: env["NIXAMP_X402"] === "1",
|
|
136
|
+
payTo: env["NIXAMP_PAY_TO"] ?? "",
|
|
137
|
+
coinpayKey: env["COINPAY_X402_KEY"] ?? "",
|
|
138
|
+
priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : DEFAULT_PAYWALL.priceCents,
|
|
139
|
+
passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : DEFAULT_PAYWALL.passMinutes,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The directory's answer may carry a configuration, which is how a server is
|
|
144
|
+
* turned on and off from nixamp.com. Anything missing keeps what it had, and
|
|
145
|
+
* a payTo the operator did not set is never invented here.
|
|
146
|
+
*/
|
|
147
|
+
export function applyRemoteConfig(current, remote) {
|
|
148
|
+
if (typeof remote !== "object" || remote === null)
|
|
149
|
+
return current;
|
|
150
|
+
const record = remote;
|
|
151
|
+
const price = Number(record["priceCents"]);
|
|
152
|
+
const minutes = Number(record["passMinutes"]);
|
|
153
|
+
return {
|
|
154
|
+
enabled: typeof record["enabled"] === "boolean" ? record["enabled"] : current.enabled,
|
|
155
|
+
payTo: typeof record["payTo"] === "string" && record["payTo"] ? record["payTo"] : current.payTo,
|
|
156
|
+
coinpayKey: typeof record["coinpayKey"] === "string" && record["coinpayKey"]
|
|
157
|
+
? record["coinpayKey"]
|
|
158
|
+
: current.coinpayKey,
|
|
159
|
+
priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : current.priceCents,
|
|
160
|
+
passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : current.passMinutes,
|
|
161
|
+
};
|
|
162
|
+
}
|
package/dist/playlist.js
CHANGED
|
@@ -6,6 +6,11 @@ import { isHls, isPlaylistFile, isRemote, nameOf, parseM3u, parsePls, } from "./
|
|
|
6
6
|
export const AUDIO_EXTENSIONS = new Set([
|
|
7
7
|
".mp3", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac",
|
|
8
8
|
".wav", ".wma", ".aiff", ".aif", ".alac", ".mp4", ".webm",
|
|
9
|
+
// Video containers, for their audio. Everything on the way out of here is
|
|
10
|
+
// already decoded by ffmpeg and re-encoded to MP3 with -vn, so a film is a
|
|
11
|
+
// long track with a picture nobody asked for -- and a library of them was
|
|
12
|
+
// invisible to nixamp for want of the extension being on this list.
|
|
13
|
+
".mkv", ".avi", ".mov", ".m4v", ".mpg", ".mpeg", ".wmv", ".flv",
|
|
9
14
|
]);
|
|
10
15
|
export function isAudio(path) {
|
|
11
16
|
const dot = path.lastIndexOf(".");
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DEFAULT_DIRECTORY, type Listing } from "./directory.ts";
|
|
2
|
+
export interface PublishTarget {
|
|
3
|
+
directory: string;
|
|
4
|
+
name: string;
|
|
5
|
+
/** The listen link: what a stranger opens. Never the control key. */
|
|
6
|
+
url: string;
|
|
7
|
+
/**
|
|
8
|
+
* The same stream as bytes, for a listener that cannot hold a cookie.
|
|
9
|
+
*
|
|
10
|
+
* The phone line plays this address into a call. The listen link cannot be
|
|
11
|
+
* played: it is a redirect that sets a cookie, and Telnyx fetching it once
|
|
12
|
+
* gets a 401 in JSON, which is a caller hearing nothing.
|
|
13
|
+
*/
|
|
14
|
+
audio?: string;
|
|
15
|
+
tracks: number;
|
|
16
|
+
nowPlaying: () => string;
|
|
17
|
+
/**
|
|
18
|
+
* The account this stream belongs to, from `nixamp login`.
|
|
19
|
+
*
|
|
20
|
+
* The directory used to take anybody's word for a listing. It cannot any
|
|
21
|
+
* more: a listing now carries a phone code people dial and minutes somebody
|
|
22
|
+
* pays for, so it has to be attributable. Reading the directory is still
|
|
23
|
+
* open to everyone -- it is announcing that needs a name behind it.
|
|
24
|
+
*/
|
|
25
|
+
token?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Called with whatever configuration the directory sent back. This is how
|
|
28
|
+
* nixamp.com turns x402 on and off for a server without it restarting.
|
|
29
|
+
*/
|
|
30
|
+
onConfig?: (config: unknown) => void;
|
|
31
|
+
/** Called when the directory refused us for want of an account. */
|
|
32
|
+
onRefused?: () => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Ask, with yes as the default. Returns false without asking when there is no
|
|
36
|
+
* terminal on the other end, which is the case for the daemon and for CI.
|
|
37
|
+
*/
|
|
38
|
+
export declare function confirm(question: string, tty?: boolean): Promise<boolean>;
|
|
39
|
+
/**
|
|
40
|
+
* Announce, then keep announcing. The directory forgets an entry that stops
|
|
41
|
+
* renewing, so stopping the heartbeat is how a stream leaves the list even if
|
|
42
|
+
* the process dies without saying goodbye.
|
|
43
|
+
*/
|
|
44
|
+
export declare class Publisher {
|
|
45
|
+
private readonly target;
|
|
46
|
+
private readonly fetcher;
|
|
47
|
+
private id;
|
|
48
|
+
private timer;
|
|
49
|
+
/** Whether we have already said that the directory wants an account. */
|
|
50
|
+
private refused;
|
|
51
|
+
constructor(target: PublishTarget, fetcher?: typeof fetch);
|
|
52
|
+
start(): Promise<Listing | null>;
|
|
53
|
+
announce(): Promise<Listing | null>;
|
|
54
|
+
/** Leave the list now rather than waiting to be forgotten. */
|
|
55
|
+
stop(): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
export { DEFAULT_DIRECTORY };
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing to the directory, and asking first.
|
|
3
|
+
*
|
|
4
|
+
* Listing a stream tells the world an address it can reach you on, so it is
|
|
5
|
+
* never done silently. The prompt defaults to yes; a terminal that cannot ask
|
|
6
|
+
* defaults to no, because "there was nobody to ask" is not consent.
|
|
7
|
+
*/
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { DEFAULT_DIRECTORY, HEARTBEAT_MS } from "./directory.js";
|
|
10
|
+
/**
|
|
11
|
+
* Ask, with yes as the default. Returns false without asking when there is no
|
|
12
|
+
* terminal on the other end, which is the case for the daemon and for CI.
|
|
13
|
+
*/
|
|
14
|
+
export async function confirm(question, tty = process.stdin.isTTY === true) {
|
|
15
|
+
if (!tty)
|
|
16
|
+
return false;
|
|
17
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
18
|
+
try {
|
|
19
|
+
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
|
20
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
rl.close();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Announce, then keep announcing. The directory forgets an entry that stops
|
|
28
|
+
* renewing, so stopping the heartbeat is how a stream leaves the list even if
|
|
29
|
+
* the process dies without saying goodbye.
|
|
30
|
+
*/
|
|
31
|
+
export class Publisher {
|
|
32
|
+
target;
|
|
33
|
+
fetcher;
|
|
34
|
+
id = null;
|
|
35
|
+
timer = null;
|
|
36
|
+
/** Whether we have already said that the directory wants an account. */
|
|
37
|
+
refused = false;
|
|
38
|
+
constructor(target, fetcher = fetch) {
|
|
39
|
+
this.target = target;
|
|
40
|
+
this.fetcher = fetcher;
|
|
41
|
+
}
|
|
42
|
+
async start() {
|
|
43
|
+
const first = await this.announce();
|
|
44
|
+
this.timer = setInterval(() => void this.announce(), HEARTBEAT_MS);
|
|
45
|
+
this.timer.unref?.();
|
|
46
|
+
return first;
|
|
47
|
+
}
|
|
48
|
+
async announce() {
|
|
49
|
+
try {
|
|
50
|
+
const response = await this.fetcher(`${this.target.directory}/api/directory`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
"content-type": "application/json",
|
|
54
|
+
...(this.target.token ? { authorization: `Bearer ${this.target.token}` } : {}),
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
...(this.id ? { id: this.id } : {}),
|
|
58
|
+
name: this.target.name,
|
|
59
|
+
url: this.target.url,
|
|
60
|
+
...(this.target.audio ? { audio: this.target.audio } : {}),
|
|
61
|
+
tracks: this.target.tracks,
|
|
62
|
+
nowPlaying: this.target.nowPlaying(),
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
// Worth telling the operator about exactly once. A heartbeat that is
|
|
67
|
+
// refused every 90 seconds should not print every 90 seconds, and
|
|
68
|
+
// "could not reach the directory" would be the wrong thing to say
|
|
69
|
+
// about a directory that answered perfectly clearly.
|
|
70
|
+
if (response.status === 401 && !this.refused) {
|
|
71
|
+
this.refused = true;
|
|
72
|
+
this.target.onRefused?.();
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const listing = (await response.json());
|
|
77
|
+
this.id = listing.id;
|
|
78
|
+
if (listing.config !== undefined)
|
|
79
|
+
this.target.onConfig?.(listing.config);
|
|
80
|
+
return listing;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// The directory being down is not a reason for a player to stop playing.
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Leave the list now rather than waiting to be forgotten. */
|
|
88
|
+
async stop() {
|
|
89
|
+
if (this.timer)
|
|
90
|
+
clearInterval(this.timer);
|
|
91
|
+
this.timer = null;
|
|
92
|
+
if (this.id === null)
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
await this.fetcher(`${this.target.directory}/api/directory?id=${encodeURIComponent(this.id)}`, {
|
|
96
|
+
method: "DELETE",
|
|
97
|
+
...(this.target.token ? { headers: { authorization: `Bearer ${this.target.token}` } } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// It expires on its own within the TTL, which is the point of the TTL.
|
|
102
|
+
}
|
|
103
|
+
this.id = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export { DEFAULT_DIRECTORY };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Channels } from "./channels.ts";
|
|
2
|
+
export interface RtmpSlot {
|
|
3
|
+
port: number;
|
|
4
|
+
/** The channel a publisher on this port lands on. */
|
|
5
|
+
id: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Arm a listener per slot, and arm it again after each publisher leaves --
|
|
9
|
+
* otherwise a broadcaster who reconnects finds nothing listening.
|
|
10
|
+
*/
|
|
11
|
+
export declare class RtmpListeners {
|
|
12
|
+
private readonly channels;
|
|
13
|
+
private readonly ffmpeg;
|
|
14
|
+
private readonly key;
|
|
15
|
+
private readonly running;
|
|
16
|
+
private stopped;
|
|
17
|
+
constructor(channels: Channels, ffmpeg: string[], key: string);
|
|
18
|
+
listen(slots: RtmpSlot[]): void;
|
|
19
|
+
private arm;
|
|
20
|
+
private done;
|
|
21
|
+
stop(): void;
|
|
22
|
+
}
|
package/dist/rtmp-in.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RTMP publishers, several at once.
|
|
3
|
+
*
|
|
4
|
+
* ffmpeg's RTMP listener serves one connection and exits, so N simultaneous
|
|
5
|
+
* publishers means N listeners on N ports. That is the honest cost of using
|
|
6
|
+
* ffmpeg as the RTMP server rather than implementing the protocol, and it buys
|
|
7
|
+
* a broadcaster that every phone and desktop app already speaks.
|
|
8
|
+
*
|
|
9
|
+
* Each listener decodes to MP3 itself, so its channel fans those bytes out
|
|
10
|
+
* without decoding them a second time.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
/**
|
|
14
|
+
* Arm a listener per slot, and arm it again after each publisher leaves --
|
|
15
|
+
* otherwise a broadcaster who reconnects finds nothing listening.
|
|
16
|
+
*/
|
|
17
|
+
export class RtmpListeners {
|
|
18
|
+
channels;
|
|
19
|
+
ffmpeg;
|
|
20
|
+
key;
|
|
21
|
+
running = new Map();
|
|
22
|
+
stopped = false;
|
|
23
|
+
constructor(channels, ffmpeg, key) {
|
|
24
|
+
this.channels = channels;
|
|
25
|
+
this.ffmpeg = ffmpeg;
|
|
26
|
+
this.key = key;
|
|
27
|
+
}
|
|
28
|
+
listen(slots) {
|
|
29
|
+
for (const slot of slots)
|
|
30
|
+
this.arm(slot);
|
|
31
|
+
}
|
|
32
|
+
arm(slot) {
|
|
33
|
+
if (this.stopped || this.running.has(slot.port))
|
|
34
|
+
return;
|
|
35
|
+
const [command, ...prefix] = this.ffmpeg;
|
|
36
|
+
const child = spawn(command, [
|
|
37
|
+
...prefix,
|
|
38
|
+
"-hide_banner",
|
|
39
|
+
"-loglevel", "error",
|
|
40
|
+
"-rtmp_listen", "1",
|
|
41
|
+
// Wait indefinitely: a stream that starts tomorrow is still the stream.
|
|
42
|
+
"-timeout", "-1",
|
|
43
|
+
"-f", "flv",
|
|
44
|
+
"-i", `rtmp://0.0.0.0:${slot.port}/live/${this.key}`,
|
|
45
|
+
"-vn",
|
|
46
|
+
"-c:a", "libmp3lame",
|
|
47
|
+
"-b:a", "192k",
|
|
48
|
+
"-y",
|
|
49
|
+
"-f", "mp3",
|
|
50
|
+
"pipe:1",
|
|
51
|
+
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
52
|
+
this.running.set(slot.port, child);
|
|
53
|
+
// The first bytes are the only honest signal that a publisher turned up:
|
|
54
|
+
// ffmpeg does not announce a connect, and a healthy stream says nothing at
|
|
55
|
+
// -loglevel error.
|
|
56
|
+
let channel = null;
|
|
57
|
+
child.stdout?.on("data", (chunk) => {
|
|
58
|
+
channel ??= this.channels.attach(slot.id, "an RTMP publisher", "flv", "rtmp");
|
|
59
|
+
channel?.feed(chunk);
|
|
60
|
+
});
|
|
61
|
+
child.stdout?.on("error", () => child.kill("SIGKILL"));
|
|
62
|
+
child.on("error", () => this.done(slot, child, channel));
|
|
63
|
+
child.on("close", () => this.done(slot, child, channel));
|
|
64
|
+
}
|
|
65
|
+
done(slot, child, channel) {
|
|
66
|
+
if (this.running.get(slot.port) !== child)
|
|
67
|
+
return;
|
|
68
|
+
this.running.delete(slot.port);
|
|
69
|
+
channel?.close();
|
|
70
|
+
if (!this.stopped)
|
|
71
|
+
this.arm(slot);
|
|
72
|
+
}
|
|
73
|
+
stop() {
|
|
74
|
+
this.stopped = true;
|
|
75
|
+
for (const child of this.running.values())
|
|
76
|
+
child.kill("SIGKILL");
|
|
77
|
+
this.running.clear();
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
2
|
import { Connections } from "./connections.ts";
|
|
3
|
+
import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast.ts";
|
|
4
|
+
import { Ingest } from "./ingest.ts";
|
|
5
|
+
import { Channels } from "./channels.ts";
|
|
6
|
+
import { Accounts } from "./accounts.ts";
|
|
7
|
+
import { Owner } from "./owner.ts";
|
|
8
|
+
import { Directory } from "./directory.ts";
|
|
9
|
+
import { PartyLine } from "./partyline.ts";
|
|
10
|
+
import { Follows } from "./follows.ts";
|
|
3
11
|
import { type Tools, type Track } from "./audio.ts";
|
|
4
12
|
import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
|
|
5
13
|
export declare const SERVE_BAND_COUNT = 24;
|
|
@@ -28,6 +36,41 @@ export interface ServeOptions {
|
|
|
28
36
|
* failed instead of started.
|
|
29
37
|
*/
|
|
30
38
|
announce: boolean;
|
|
39
|
+
/** Host the public directory. Only the deployment behind nixamp.com does. */
|
|
40
|
+
directory: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* List this stream at nixamp.com/directory. "ask" prompts, and is the
|
|
43
|
+
* default: publishing an address without being asked is not something a
|
|
44
|
+
* player gets to decide for you.
|
|
45
|
+
*/
|
|
46
|
+
publish: "ask" | "yes" | "no";
|
|
47
|
+
/** What to call it in the list. Defaults to this machine's hostname. */
|
|
48
|
+
name: string;
|
|
49
|
+
/**
|
|
50
|
+
* Charge for listening once the stream is busy. Off unless asked for, and
|
|
51
|
+
* useless without somewhere to pay: see NIXAMP_PAY_TO.
|
|
52
|
+
*/
|
|
53
|
+
x402: boolean;
|
|
54
|
+
/** The account id that may administer this server, if not the signed-in one. */
|
|
55
|
+
owner: string;
|
|
56
|
+
/** Accept a live stream from a phone or a desktop, over HTTP. */
|
|
57
|
+
ingest: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Also listen for RTMP publishers on this port, which is what OBS, Larix and
|
|
60
|
+
* anything else native speaks. 0 means do not.
|
|
61
|
+
*/
|
|
62
|
+
rtmpIn: number;
|
|
63
|
+
/**
|
|
64
|
+
* How many RTMP publishers may be live at once. ffmpeg's listener serves one
|
|
65
|
+
* connection per process, so this is a port and a process each: 1935, 1936,
|
|
66
|
+
* and so on. HTTP publishers are not limited by this.
|
|
67
|
+
*/
|
|
68
|
+
rtmpStreams: number;
|
|
69
|
+
/**
|
|
70
|
+
* RTMP destinations, as `name=rtmp://host/app/key` or `youtube=key` for one
|
|
71
|
+
* of the presets. Repeatable.
|
|
72
|
+
*/
|
|
73
|
+
rtmp: string[];
|
|
31
74
|
}
|
|
32
75
|
/**
|
|
33
76
|
* Flags are parsed by hand: three of them do not justify a dependency, and the
|
|
@@ -120,6 +163,12 @@ export interface HandlerOptions {
|
|
|
120
163
|
version: string;
|
|
121
164
|
/** The key from the share link, or null to serve to anyone who can connect. */
|
|
122
165
|
key?: string | null;
|
|
166
|
+
/**
|
|
167
|
+
* A second key that may listen but not drive. The public directory hands
|
|
168
|
+
* this one out: a link that lets a stranger pause your music is not a link
|
|
169
|
+
* you can publish.
|
|
170
|
+
*/
|
|
171
|
+
listenKey?: string | null;
|
|
123
172
|
/** How to run ffmpeg, for the sources a browser cannot play by itself. */
|
|
124
173
|
ffmpeg?: string[];
|
|
125
174
|
/** Who is listening, for the admin view. */
|
|
@@ -129,6 +178,43 @@ export interface HandlerOptions {
|
|
|
129
178
|
* imported so the handler stays a plain function of a request.
|
|
130
179
|
*/
|
|
131
180
|
load: (source: string) => Promise<Track[]>;
|
|
181
|
+
/**
|
|
182
|
+
* The public directory, on the instance that hosts one. Only nixamp.com
|
|
183
|
+
* passes this; a nixamp on your laptop is a publisher, not a registry.
|
|
184
|
+
*/
|
|
185
|
+
directory?: Directory;
|
|
186
|
+
/** Answers a request itself when listening has to be paid for. */
|
|
187
|
+
paywall?: (request: IncomingMessage, response: ServerResponse, path: string) => Promise<boolean>;
|
|
188
|
+
/** Live audio coming in from a phone or a desktop. */
|
|
189
|
+
ingest?: Ingest;
|
|
190
|
+
/** Several live streams at once, each with its own audience. */
|
|
191
|
+
channels?: Channels;
|
|
192
|
+
/** Live audio going out to RTMP. */
|
|
193
|
+
broadcaster?: Broadcaster;
|
|
194
|
+
/** Where a broadcast should send, and what it should look like. */
|
|
195
|
+
broadcast?: () => {
|
|
196
|
+
destinations: Destination[];
|
|
197
|
+
settings: EncoderSettings;
|
|
198
|
+
};
|
|
199
|
+
/** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
|
|
200
|
+
accounts?: Accounts;
|
|
201
|
+
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
202
|
+
secureCookies?: boolean;
|
|
203
|
+
/** Who may administer this server. */
|
|
204
|
+
owner?: Owner;
|
|
205
|
+
/**
|
|
206
|
+
* The dial-in party line, on the instance that answers the phone number.
|
|
207
|
+
* Only nixamp.com passes this; a nixamp on a laptop has no number.
|
|
208
|
+
*/
|
|
209
|
+
partyLine?: PartyLine;
|
|
210
|
+
/**
|
|
211
|
+
* Following broadcasters, and where to reach the people who do. Durable,
|
|
212
|
+
* unlike everything else here, because the point of a follow is to outlive
|
|
213
|
+
* the stream.
|
|
214
|
+
*/
|
|
215
|
+
follows?: Follows;
|
|
216
|
+
/** The VAPID public key a browser needs before it can subscribe. */
|
|
217
|
+
vapidPublicKey?: string;
|
|
132
218
|
}
|
|
133
219
|
/**
|
|
134
220
|
* The whole HTTP surface, as a plain function of a request — so a test can
|
|
@@ -137,3 +223,11 @@ export interface HandlerOptions {
|
|
|
137
223
|
export declare function createHandler(engine: Engine, options: HandlerOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
|
138
224
|
export declare function createServer(engine: Engine, options: HandlerOptions): Server;
|
|
139
225
|
export declare function serve(argv: string[], version?: string): Promise<void>;
|
|
226
|
+
/**
|
|
227
|
+
* `--rtmp youtube=<key>` or `--rtmp name=rtmp://host/app/key`.
|
|
228
|
+
*
|
|
229
|
+
* A key is a password, so it is taken from the command line or the environment
|
|
230
|
+
* and never from a request: a client that could name its own destination could
|
|
231
|
+
* point your broadcast at itself.
|
|
232
|
+
*/
|
|
233
|
+
export declare function parseDestinations(specs: string[]): Destination[];
|