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/accounts.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounts on nixamp.com.
|
|
3
|
+
*
|
|
4
|
+
* The house auth module does the work: password and JWT, over the Postgres
|
|
5
|
+
* adapter. This is the shape nixamp needs around it, and the two things the
|
|
6
|
+
* module gets wrong from a caller's point of view:
|
|
7
|
+
*
|
|
8
|
+
* - `login()` and `register()` THROW on a bad password or a taken address
|
|
9
|
+
* rather than resolving `{ success: false }`, so a bare `if (!result.success)`
|
|
10
|
+
* never runs. Everything here answers a result instead.
|
|
11
|
+
* - `validateToken()` resolves to the claims directly, not to a wrapper like
|
|
12
|
+
* the other two, so the shapes differ between calls.
|
|
13
|
+
* - `register()` without `autoVerify` creates an account that `login()` will
|
|
14
|
+
* refuse for ever, and returns no tokens. nixamp sends no email, so there
|
|
15
|
+
* would be nothing to click.
|
|
16
|
+
*
|
|
17
|
+
* No magic link: a link in an inbox is no use on a television or a phone that
|
|
18
|
+
* is not the one you read mail on.
|
|
19
|
+
*/
|
|
20
|
+
import { createAuthSystem, PostgresAdapter } from "@profullstack/auth-system";
|
|
21
|
+
|
|
22
|
+
export interface Account {
|
|
23
|
+
id: string;
|
|
24
|
+
email: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AuthResult {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
account: Account | null;
|
|
30
|
+
token: string;
|
|
31
|
+
/** Safe to show a stranger: it never says whether an address is registered. */
|
|
32
|
+
error: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NO_ACCOUNT: AuthResult = { ok: false, account: null, token: "", error: "" };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The same sentence for a wrong password and an address with no account.
|
|
39
|
+
* Saying which is how an endpoint tells a stranger who has registered.
|
|
40
|
+
*/
|
|
41
|
+
const REFUSED = "that email and password do not match an account";
|
|
42
|
+
|
|
43
|
+
export interface AccountsOptions {
|
|
44
|
+
/** postgres://user:pass@host/db */
|
|
45
|
+
connectionString: string;
|
|
46
|
+
/** Signing secret. Without one, every session dies on restart. */
|
|
47
|
+
secret: string;
|
|
48
|
+
/** Injected by the tests, which have no database. */
|
|
49
|
+
system?: AuthLike;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The slice of the auth system nixamp uses. */
|
|
53
|
+
export interface AuthLike {
|
|
54
|
+
register(input: { email: string; password: string; autoVerify?: boolean }): Promise<unknown>;
|
|
55
|
+
login(input: { email: string; password: string }): Promise<unknown>;
|
|
56
|
+
validateToken(token: string): Promise<unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pull an account and a token out of whatever shape the module returned. */
|
|
60
|
+
export function readResult(value: unknown): AuthResult {
|
|
61
|
+
const record = (value ?? {}) as Record<string, unknown>;
|
|
62
|
+
const user = (record["user"] ?? {}) as Record<string, unknown>;
|
|
63
|
+
const tokens = (record["tokens"] ?? {}) as Record<string, unknown>;
|
|
64
|
+
const id = typeof user["id"] === "string" ? user["id"] : "";
|
|
65
|
+
const email = typeof user["email"] === "string" ? user["email"] : "";
|
|
66
|
+
const token = typeof tokens["accessToken"] === "string" ? tokens["accessToken"] : "";
|
|
67
|
+
if (!id || !token) return { ...NO_ACCOUNT, error: REFUSED };
|
|
68
|
+
return { ok: true, account: { id, email }, token, error: "" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `validateToken` answers claims directly, unlike login and register. */
|
|
72
|
+
export function readClaims(value: unknown): Account | null {
|
|
73
|
+
const claims = (value ?? {}) as Record<string, unknown>;
|
|
74
|
+
const id = typeof claims["userId"] === "string" ? claims["userId"] : "";
|
|
75
|
+
const email = typeof claims["email"] === "string" ? claims["email"] : "";
|
|
76
|
+
return id ? { id, email } : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** An address that could exist, and a password long enough to be worth having. */
|
|
80
|
+
export function checkCredentials(email: unknown, password: unknown): string {
|
|
81
|
+
if (typeof email !== "string" || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
|
|
82
|
+
return "that does not look like an email address";
|
|
83
|
+
}
|
|
84
|
+
if (typeof password !== "string" || password.length < 10) {
|
|
85
|
+
// Length is checked here so a hopeless password never reaches the
|
|
86
|
+
// database. The auth module then applies its own composition rules on top,
|
|
87
|
+
// and its refusals are passed through rather than swallowed.
|
|
88
|
+
return "a password needs at least 10 characters";
|
|
89
|
+
}
|
|
90
|
+
if (password.length > 200) return "that password is too long";
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class Accounts {
|
|
95
|
+
private readonly system: AuthLike;
|
|
96
|
+
|
|
97
|
+
constructor(options: AccountsOptions) {
|
|
98
|
+
this.system =
|
|
99
|
+
options.system ??
|
|
100
|
+
(createAuthSystem({
|
|
101
|
+
adapter: new PostgresAdapter({ connectionString: options.connectionString }),
|
|
102
|
+
jwtSecret: options.secret,
|
|
103
|
+
}) as AuthLike);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async signUp(email: unknown, password: unknown): Promise<AuthResult> {
|
|
107
|
+
const wrong = checkCredentials(email, password);
|
|
108
|
+
if (wrong) return { ...NO_ACCOUNT, error: wrong };
|
|
109
|
+
try {
|
|
110
|
+
// autoVerify does two things, and both are necessary here: without it
|
|
111
|
+
// the account is created unverified and login() refuses it forever --
|
|
112
|
+
// nixamp sends no email, so there is nothing to click -- and register()
|
|
113
|
+
// returns no tokens, so signing up would not sign you in.
|
|
114
|
+
return readResult(
|
|
115
|
+
await this.system.register({
|
|
116
|
+
email: email as string,
|
|
117
|
+
password: password as string,
|
|
118
|
+
autoVerify: true,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const message = (error as Error).message ?? "";
|
|
123
|
+
// "already exists" is the one case worth naming: a sign-up form that
|
|
124
|
+
// will not say why is a sign-up form people give up on. It reveals
|
|
125
|
+
// nothing that trying to sign up does not reveal anyway.
|
|
126
|
+
if (/exist|taken|duplicate/i.test(message)) {
|
|
127
|
+
return { ...NO_ACCOUNT, error: "there is already an account with that email" };
|
|
128
|
+
}
|
|
129
|
+
// The module has its own password rules -- an uppercase letter, and so
|
|
130
|
+
// on -- and refuses with a sentence saying which. Hiding that behind
|
|
131
|
+
// "could not create that account" leaves someone retyping a password
|
|
132
|
+
// that will never be accepted.
|
|
133
|
+
const complaint = /^Invalid (?:password|email)[:\s]+(.*)$/i.exec(message);
|
|
134
|
+
if (complaint?.[1]) return { ...NO_ACCOUNT, error: complaint[1].trim().toLowerCase() };
|
|
135
|
+
return { ...NO_ACCOUNT, error: "could not create that account" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async signIn(email: unknown, password: unknown): Promise<AuthResult> {
|
|
140
|
+
if (checkCredentials(email, password)) return { ...NO_ACCOUNT, error: REFUSED };
|
|
141
|
+
try {
|
|
142
|
+
return readResult(await this.system.login({ email: email as string, password: password as string }));
|
|
143
|
+
} catch {
|
|
144
|
+
// login() throws on bad credentials, so this is the ordinary path.
|
|
145
|
+
return { ...NO_ACCOUNT, error: REFUSED };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async whoIs(token: string): Promise<Account | null> {
|
|
150
|
+
if (!token) return null;
|
|
151
|
+
try {
|
|
152
|
+
return readClaims(await this.system.validateToken(token));
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The bearer token on a request, from the header or the session cookie. */
|
|
160
|
+
export function tokenFrom(headers: Record<string, string | string[] | undefined>): string {
|
|
161
|
+
const authorization = headers["authorization"];
|
|
162
|
+
const header = Array.isArray(authorization) ? authorization[0] : authorization;
|
|
163
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(header ?? "")?.[1];
|
|
164
|
+
if (bearer) return bearer.trim();
|
|
165
|
+
|
|
166
|
+
const cookie = Array.isArray(headers["cookie"]) ? headers["cookie"][0] : headers["cookie"];
|
|
167
|
+
for (const part of (cookie ?? "").split(";")) {
|
|
168
|
+
const [name, ...rest] = part.trim().split("=");
|
|
169
|
+
if (name === "nixamp_session" && rest.length > 0) return decodeURIComponent(rest.join("="));
|
|
170
|
+
}
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The session cookie. HttpOnly because nothing in the page reads it -- the
|
|
176
|
+
* browser attaches it by itself -- and Secure only where the page was served
|
|
177
|
+
* over https, since a nixamp on your own network is plain http.
|
|
178
|
+
*/
|
|
179
|
+
export function sessionCookie(token: string, secure: boolean): string {
|
|
180
|
+
const parts = [
|
|
181
|
+
`nixamp_session=${encodeURIComponent(token)}`,
|
|
182
|
+
"Path=/",
|
|
183
|
+
"Max-Age=2592000",
|
|
184
|
+
"SameSite=Lax",
|
|
185
|
+
"HttpOnly",
|
|
186
|
+
];
|
|
187
|
+
if (secure) parts.push("Secure");
|
|
188
|
+
return parts.join("; ");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function clearedCookie(): string {
|
|
192
|
+
return "nixamp_session=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly";
|
|
193
|
+
}
|
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
|
+
}
|
package/src/broadcast.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broadcasting out to RTMP, to as many places at once as you like.
|
|
3
|
+
*
|
|
4
|
+
* One ffmpeg, one encode, many outputs, through the `tee` muxer. Running an
|
|
5
|
+
* ffmpeg per destination is the obvious shape and it encodes the same frames
|
|
6
|
+
* four times; tee encodes once and writes the result to every URL.
|
|
7
|
+
*
|
|
8
|
+
* The encoder settings are PairUX's, which learned them the hard way against
|
|
9
|
+
* the real platforms: a one-second keyframe interval because YouTube stalls on
|
|
10
|
+
* ffmpeg's default, a forced constant frame rate because a variable-rate source
|
|
11
|
+
* makes YouTube report "not receiving enough video", and yuv420p because that
|
|
12
|
+
* is what RTMP platforms accept.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
export interface Destination {
|
|
17
|
+
id: string;
|
|
18
|
+
/** What to call it: "YouTube", "X", the name of a server. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** rtmp://a.rtmp.youtube.com/live2 — without the key. */
|
|
21
|
+
url: string;
|
|
22
|
+
/** The stream key. It never leaves the machine: see redact(). */
|
|
23
|
+
key: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EncoderSettings {
|
|
28
|
+
/** kbps. */
|
|
29
|
+
videoBitrate: number;
|
|
30
|
+
audioBitrate: number;
|
|
31
|
+
framerate: number;
|
|
32
|
+
/** Seconds between keyframes. One, unless you enjoy YouTube stalling. */
|
|
33
|
+
keyframeInterval: number;
|
|
34
|
+
resolution: "720p" | "1080p";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_ENCODER: EncoderSettings = {
|
|
38
|
+
videoBitrate: 4500,
|
|
39
|
+
audioBitrate: 128,
|
|
40
|
+
framerate: 30,
|
|
41
|
+
keyframeInterval: 1,
|
|
42
|
+
resolution: "1080p",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The RTMP ingest URLs of the places people actually go live. */
|
|
46
|
+
export const PRESETS: Record<string, string> = {
|
|
47
|
+
youtube: "rtmp://a.rtmp.youtube.com/live2",
|
|
48
|
+
x: "rtmp://ingest.x.com:1935/live",
|
|
49
|
+
facebook: "rtmps://live-api-s.facebook.com:443/rtmp",
|
|
50
|
+
tiktok: "rtmp://push.tiktokcdn.com/live",
|
|
51
|
+
twitch: "rtmp://live.twitch.tv/app",
|
|
52
|
+
kick: "rtmps://fa723fc1b171.global-contribute.live-video.net:443/app",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function resolutionOf(resolution: EncoderSettings["resolution"]): { width: number; height: number } {
|
|
56
|
+
return resolution === "720p" ? { width: 1280, height: 720 } : { width: 1920, height: 1080 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The full ingest URL. Built here so a key is never assembled by a client. */
|
|
60
|
+
export function ingestUrl(destination: Destination): string {
|
|
61
|
+
return `${destination.url.replace(/\/+$/, "")}/${destination.key}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Somewhere to actually send RTMP. */
|
|
65
|
+
export function isRtmp(url: string): boolean {
|
|
66
|
+
return /^rtmps?:\/\/[^\s/]+/i.test(url);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A destination as it may be shown to anyone. A stream key is a password: it
|
|
71
|
+
* lets a stranger broadcast as you until you rotate it.
|
|
72
|
+
*/
|
|
73
|
+
export function redact(destination: Destination): Omit<Destination, "key"> & { key: string } {
|
|
74
|
+
const tail = destination.key.slice(-4);
|
|
75
|
+
return { ...destination, key: destination.key ? `••••${tail}` : "" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A tee output. `onfail=ignore` is the important part: without it one dead
|
|
80
|
+
* destination takes the whole broadcast down with it, and the one that dies is
|
|
81
|
+
* usually the one whose key expired without telling you.
|
|
82
|
+
*/
|
|
83
|
+
export function teeOutput(url: string, options: string[] = ["f=flv"]): string {
|
|
84
|
+
return `[${[...options, "onfail=ignore"].join(":")}]${url}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface BroadcastPlan {
|
|
88
|
+
source: string;
|
|
89
|
+
destinations: Destination[];
|
|
90
|
+
settings: EncoderSettings;
|
|
91
|
+
/** Also produce web-playable audio on stdout, from the same decode. */
|
|
92
|
+
webAudio: boolean;
|
|
93
|
+
/** The source has no video track, so one has to be invented for RTMP. */
|
|
94
|
+
needsVideo: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The whole ffmpeg command.
|
|
99
|
+
*
|
|
100
|
+
* RTMP platforms want a video track even when what you are sending is music,
|
|
101
|
+
* so a silent source gets a flat colour at the chosen size. It is what a radio
|
|
102
|
+
* stream looks like on YouTube either way.
|
|
103
|
+
*/
|
|
104
|
+
export function buildBroadcastArgs(plan: BroadcastPlan): string[] {
|
|
105
|
+
const { width, height } = resolutionOf(plan.settings.resolution);
|
|
106
|
+
const gop = plan.settings.framerate * plan.settings.keyframeInterval;
|
|
107
|
+
|
|
108
|
+
const args = ["-hide_banner", "-loglevel", "error"];
|
|
109
|
+
|
|
110
|
+
// -re only for a file: a live source already arrives in real time, and
|
|
111
|
+
// throttling it a second time drifts further behind with every track.
|
|
112
|
+
if (!/^(https?|rtmps?|pipe):/i.test(plan.source) && plan.source !== "pipe:0") args.push("-re");
|
|
113
|
+
|
|
114
|
+
if (plan.needsVideo) {
|
|
115
|
+
args.push("-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=${plan.settings.framerate}`);
|
|
116
|
+
}
|
|
117
|
+
args.push("-i", plan.source);
|
|
118
|
+
|
|
119
|
+
// Video is always input 0: either the invented colour, or the source's own.
|
|
120
|
+
// Audio moves to input 1 when a colour was pushed in front of it.
|
|
121
|
+
args.push("-map", "0:v", "-map", plan.needsVideo ? "1:a" : "0:a");
|
|
122
|
+
|
|
123
|
+
args.push(
|
|
124
|
+
"-c:v", "libx264",
|
|
125
|
+
"-preset", "veryfast",
|
|
126
|
+
"-tune", "zerolatency",
|
|
127
|
+
"-b:v", `${plan.settings.videoBitrate}k`,
|
|
128
|
+
"-maxrate", `${Math.round(plan.settings.videoBitrate * 1.1)}k`,
|
|
129
|
+
"-bufsize", `${plan.settings.videoBitrate * 2}k`,
|
|
130
|
+
// A strict constant frame rate. A source that only produces frames when
|
|
131
|
+
// something changes reads to YouTube as a stream that is falling behind.
|
|
132
|
+
"-vf", `scale=${width}:${height},fps=${plan.settings.framerate}`,
|
|
133
|
+
"-pix_fmt", "yuv420p",
|
|
134
|
+
"-g", String(gop),
|
|
135
|
+
"-c:a", "aac",
|
|
136
|
+
"-b:a", `${plan.settings.audioBitrate}k`,
|
|
137
|
+
"-ar", "44100",
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const outputs = plan.destinations
|
|
141
|
+
.filter((d) => d.enabled && isRtmp(d.url))
|
|
142
|
+
.map((d) => teeOutput(ingestUrl(d)));
|
|
143
|
+
|
|
144
|
+
// The web copy rides along on the same encode, audio only, down stdout.
|
|
145
|
+
if (plan.webAudio) outputs.push(teeOutput("pipe:1", ["select=a", "f=mp3"]));
|
|
146
|
+
|
|
147
|
+
if (outputs.length === 0) return [];
|
|
148
|
+
|
|
149
|
+
// One output does not need the tee muxer, and ffmpeg reports its errors more
|
|
150
|
+
// clearly without it.
|
|
151
|
+
if (outputs.length === 1 && !plan.webAudio) {
|
|
152
|
+
const only = plan.destinations.find((d) => d.enabled && isRtmp(d.url));
|
|
153
|
+
args.push("-f", "flv", ingestUrl(only as Destination));
|
|
154
|
+
return args;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
args.push("-flags", "+global_header", "-f", "tee", outputs.join("|"));
|
|
158
|
+
return args;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type BroadcastState = "idle" | "live" | "failed";
|
|
162
|
+
|
|
163
|
+
export interface BroadcastStatus {
|
|
164
|
+
state: BroadcastState;
|
|
165
|
+
since: number | null;
|
|
166
|
+
/** Names only, and never a key. */
|
|
167
|
+
destinations: string[];
|
|
168
|
+
error: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One broadcast at a time, restarted when it dies. A live stream that stops
|
|
173
|
+
* because a platform hiccupped, and stays stopped, is worse than no feature.
|
|
174
|
+
*/
|
|
175
|
+
export class Broadcaster {
|
|
176
|
+
private child: ChildProcess | null = null;
|
|
177
|
+
private plan: BroadcastPlan | null = null;
|
|
178
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
179
|
+
private attempts = 0;
|
|
180
|
+
private state: BroadcastState = "idle";
|
|
181
|
+
private since: number | null = null;
|
|
182
|
+
private error = "";
|
|
183
|
+
|
|
184
|
+
constructor(
|
|
185
|
+
private readonly ffmpeg: string[] = ["ffmpeg"],
|
|
186
|
+
/** Injected so a test never waits five real seconds. */
|
|
187
|
+
private readonly delay = (ms: number, run: () => void) => setTimeout(run, ms),
|
|
188
|
+
) {}
|
|
189
|
+
|
|
190
|
+
status(): BroadcastStatus {
|
|
191
|
+
return {
|
|
192
|
+
state: this.state,
|
|
193
|
+
since: this.since,
|
|
194
|
+
destinations: (this.plan?.destinations ?? []).filter((d) => d.enabled).map((d) => d.name),
|
|
195
|
+
error: this.error,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
start(plan: BroadcastPlan): { ok: boolean; error: string } {
|
|
200
|
+
const args = buildBroadcastArgs(plan);
|
|
201
|
+
if (args.length === 0) return { ok: false, error: "no enabled destination with an rtmp url" };
|
|
202
|
+
|
|
203
|
+
this.stop();
|
|
204
|
+
this.plan = plan;
|
|
205
|
+
this.attempts = 0;
|
|
206
|
+
this.error = "";
|
|
207
|
+
this.spawn(args);
|
|
208
|
+
return { ok: true, error: "" };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
stop(): void {
|
|
212
|
+
if (this.timer) clearTimeout(this.timer);
|
|
213
|
+
this.timer = null;
|
|
214
|
+
this.plan = null;
|
|
215
|
+
this.state = "idle";
|
|
216
|
+
this.since = null;
|
|
217
|
+
const child = this.child;
|
|
218
|
+
this.child = null;
|
|
219
|
+
child?.kill("SIGKILL");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private spawn(args: string[]): void {
|
|
223
|
+
const [command, ...prefix] = this.ffmpeg as [string, ...string[]];
|
|
224
|
+
const child = spawn(command, [...prefix, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
225
|
+
this.child = child;
|
|
226
|
+
this.state = "live";
|
|
227
|
+
this.since = Date.now();
|
|
228
|
+
|
|
229
|
+
let tail = "";
|
|
230
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
231
|
+
tail = (tail + chunk.toString()).slice(-2000);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
child.on("error", (error) => {
|
|
235
|
+
this.error = error.message;
|
|
236
|
+
this.state = "failed";
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
child.on("close", (code) => {
|
|
240
|
+
if (this.child !== child) return; // stopped on purpose, or replaced
|
|
241
|
+
this.child = null;
|
|
242
|
+
if (code === 0) {
|
|
243
|
+
this.state = "idle";
|
|
244
|
+
this.since = null;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
this.error = tail.trim().split("\n").pop() ?? `ffmpeg exited ${code}`;
|
|
248
|
+
this.state = "failed";
|
|
249
|
+
this.retry();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Back off, but never give up entirely while a plan is set. */
|
|
254
|
+
private retry(): void {
|
|
255
|
+
const plan = this.plan;
|
|
256
|
+
if (plan === null) return;
|
|
257
|
+
this.attempts++;
|
|
258
|
+
const wait = Math.min(30_000, 1000 * 2 ** Math.min(5, this.attempts - 1));
|
|
259
|
+
this.timer = this.delay(wait, () => {
|
|
260
|
+
if (this.plan !== plan) return;
|
|
261
|
+
this.spawn(buildBroadcastArgs(plan));
|
|
262
|
+
}) as ReturnType<typeof setTimeout>;
|
|
263
|
+
}
|
|
264
|
+
}
|