nixamp 0.6.1 → 0.7.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/dist/audio.d.ts +24 -1
- package/dist/audio.js +116 -1
- package/dist/handles.d.ts +49 -0
- package/dist/handles.js +99 -0
- package/dist/invite.d.ts +52 -0
- package/dist/invite.js +58 -0
- package/dist/playlist.d.ts +16 -1
- package/dist/playlist.js +84 -7
- package/dist/server.d.ts +3 -0
- package/dist/server.js +58 -3
- package/package.json +1 -1
- package/src/audio.ts +120 -1
- package/src/handles.ts +120 -0
- package/src/invite.ts +74 -0
- package/src/playlist.ts +82 -7
- package/src/server.ts +63 -3
- package/web/dist/assets/{hls-3VKVEQE3-CSh9YGnu.js → hls-3VKVEQE3-6P5P66VW.js} +1 -1
- package/web/dist/assets/index-0Asn6zxx.js +1 -0
- package/web/dist/assets/{mpegts-DPXXHio1.js → mpegts-BUbeP1QU.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-BybaRBHq.js → mpegts-LO6RVLD6-CEVDX7YC.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-PgqcCBW4.js +0 -1
package/dist/audio.d.ts
CHANGED
|
@@ -58,6 +58,20 @@ export declare function peaks(pcm: Float32Array): [number, number];
|
|
|
58
58
|
/** Interleaved stereo down to mono, for the analyser. */
|
|
59
59
|
export declare function toMono(pcm: Float32Array): Float32Array;
|
|
60
60
|
export declare function formatTime(seconds: number): string;
|
|
61
|
+
/**
|
|
62
|
+
* The same tags, read without blocking anything.
|
|
63
|
+
*
|
|
64
|
+
* `probe` is spawnSync, and 0.5.5 tried to fix the tagging pass by yielding
|
|
65
|
+
* between files. That is not enough: each individual call still stops the
|
|
66
|
+
* process for as long as one ffprobe takes, and on a large file over a slow
|
|
67
|
+
* disk that is hundreds of milliseconds. Yield, block, yield, block, and a
|
|
68
|
+
* server delivers a stream in slivers -- measured at 357 KB/s on a machine
|
|
69
|
+
* whose disk reads at 6.5 MB/s and whose link runs at 1.4 Gbps.
|
|
70
|
+
*
|
|
71
|
+
* ffprobe still costs what it costs. It just costs it in a child process now,
|
|
72
|
+
* which is where that work belongs.
|
|
73
|
+
*/
|
|
74
|
+
export declare function probeAsync(tools: Tools, path: string): Promise<Track>;
|
|
61
75
|
/** What is actually inside a container, as opposed to what the name suggests. */
|
|
62
76
|
export interface Codecs {
|
|
63
77
|
/** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
|
|
@@ -82,4 +96,13 @@ export declare function codecsOf(tools: Tools, path: string): Promise<Codecs>;
|
|
|
82
96
|
* would cost a core per viewer and look worse. So the streams decide, one part
|
|
83
97
|
* at a time -- a film can have its video copied and only its DTS re-encoded.
|
|
84
98
|
*/
|
|
85
|
-
export declare function videoArgs(codecs: Codecs): string[];
|
|
99
|
+
export declare function videoArgs(codecs: Codecs, capKbps?: number): string[];
|
|
100
|
+
/**
|
|
101
|
+
* The width that suits a bitrate.
|
|
102
|
+
*
|
|
103
|
+
* 1080p squeezed into a megabit is worse than 360p at the same megabit: the
|
|
104
|
+
* encoder spends everything it has on detail it cannot afford and the result
|
|
105
|
+
* smears on every motion. Dropping the resolution with the bitrate is what
|
|
106
|
+
* makes a small stream watchable rather than merely small.
|
|
107
|
+
*/
|
|
108
|
+
export declare function widthFor(kbps: number): number;
|
package/dist/audio.js
CHANGED
|
@@ -204,6 +204,77 @@ export function formatTime(seconds) {
|
|
|
204
204
|
const s = total % 60;
|
|
205
205
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
206
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* The same tags, read without blocking anything.
|
|
209
|
+
*
|
|
210
|
+
* `probe` is spawnSync, and 0.5.5 tried to fix the tagging pass by yielding
|
|
211
|
+
* between files. That is not enough: each individual call still stops the
|
|
212
|
+
* process for as long as one ffprobe takes, and on a large file over a slow
|
|
213
|
+
* disk that is hundreds of milliseconds. Yield, block, yield, block, and a
|
|
214
|
+
* server delivers a stream in slivers -- measured at 357 KB/s on a machine
|
|
215
|
+
* whose disk reads at 6.5 MB/s and whose link runs at 1.4 Gbps.
|
|
216
|
+
*
|
|
217
|
+
* ffprobe still costs what it costs. It just costs it in a child process now,
|
|
218
|
+
* which is where that work belongs.
|
|
219
|
+
*/
|
|
220
|
+
export async function probeAsync(tools, path) {
|
|
221
|
+
const [cmd, ...rest] = tools.ffprobe;
|
|
222
|
+
const fallback = {
|
|
223
|
+
path,
|
|
224
|
+
title: path.split("/").pop() ?? path,
|
|
225
|
+
artist: "",
|
|
226
|
+
album: "",
|
|
227
|
+
duration: 0,
|
|
228
|
+
};
|
|
229
|
+
if (!cmd)
|
|
230
|
+
return fallback;
|
|
231
|
+
return new Promise((done) => {
|
|
232
|
+
const child = spawn(cmd, [
|
|
233
|
+
...rest,
|
|
234
|
+
"-v", "quiet", "-print_format", "json",
|
|
235
|
+
"-show_format", "-show_entries", "format_tags=title,artist,album",
|
|
236
|
+
path,
|
|
237
|
+
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
238
|
+
let out = "";
|
|
239
|
+
// A file that will not answer must not hold a place in the queue for ever.
|
|
240
|
+
const giveUp = setTimeout(() => child.kill("SIGKILL"), 20_000);
|
|
241
|
+
giveUp.unref?.();
|
|
242
|
+
child.stdout.on("data", (chunk) => {
|
|
243
|
+
if (out.length < 4 * 1024 * 1024)
|
|
244
|
+
out += chunk.toString("utf8");
|
|
245
|
+
});
|
|
246
|
+
child.on("error", () => {
|
|
247
|
+
clearTimeout(giveUp);
|
|
248
|
+
done(fallback);
|
|
249
|
+
});
|
|
250
|
+
child.on("close", (code) => {
|
|
251
|
+
clearTimeout(giveUp);
|
|
252
|
+
if (code !== 0)
|
|
253
|
+
return done(fallback);
|
|
254
|
+
done(readTags(out, fallback));
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** The tags out of ffprobe's JSON, or the filename when it said nothing useful. */
|
|
259
|
+
function readTags(stdout, fallback) {
|
|
260
|
+
try {
|
|
261
|
+
const parsed = JSON.parse(stdout);
|
|
262
|
+
const tags = parsed.format?.tags ?? {};
|
|
263
|
+
const lower = {};
|
|
264
|
+
for (const [k, v] of Object.entries(tags))
|
|
265
|
+
lower[k.toLowerCase()] = v;
|
|
266
|
+
return {
|
|
267
|
+
path: fallback.path,
|
|
268
|
+
title: lower.title || fallback.title,
|
|
269
|
+
artist: lower.artist ?? "",
|
|
270
|
+
album: lower.album ?? "",
|
|
271
|
+
duration: Number(parsed.format?.duration ?? 0) || 0,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return fallback;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
207
278
|
/**
|
|
208
279
|
* Ask ffprobe what the streams are, without holding the event loop.
|
|
209
280
|
*
|
|
@@ -253,7 +324,11 @@ export async function codecsOf(tools, path) {
|
|
|
253
324
|
* would cost a core per viewer and look worse. So the streams decide, one part
|
|
254
325
|
* at a time -- a film can have its video copied and only its DTS re-encoded.
|
|
255
326
|
*/
|
|
256
|
-
export function videoArgs(codecs) {
|
|
327
|
+
export function videoArgs(codecs, capKbps = 0) {
|
|
328
|
+
// A ceiling means re-encoding whatever is there, because you cannot cap the
|
|
329
|
+
// bitrate of a stream you are copying: copying is what "unchanged" means.
|
|
330
|
+
if (capKbps > 0)
|
|
331
|
+
return cappedArgs(capKbps);
|
|
257
332
|
// What a browser can play inside MP4 without help.
|
|
258
333
|
const keepVideo = codecs.video === "h264";
|
|
259
334
|
const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
|
|
@@ -269,3 +344,43 @@ export function videoArgs(codecs) {
|
|
|
269
344
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
|
270
345
|
];
|
|
271
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* The width that suits a bitrate.
|
|
349
|
+
*
|
|
350
|
+
* 1080p squeezed into a megabit is worse than 360p at the same megabit: the
|
|
351
|
+
* encoder spends everything it has on detail it cannot afford and the result
|
|
352
|
+
* smears on every motion. Dropping the resolution with the bitrate is what
|
|
353
|
+
* makes a small stream watchable rather than merely small.
|
|
354
|
+
*/
|
|
355
|
+
export function widthFor(kbps) {
|
|
356
|
+
if (kbps <= 800)
|
|
357
|
+
return 640;
|
|
358
|
+
if (kbps <= 1800)
|
|
359
|
+
return 854;
|
|
360
|
+
if (kbps <= 4000)
|
|
361
|
+
return 1280;
|
|
362
|
+
return 1920;
|
|
363
|
+
}
|
|
364
|
+
/** Arguments for a stream that has to fit through a link of a known size. */
|
|
365
|
+
function cappedArgs(kbps) {
|
|
366
|
+
const audioKbps = kbps <= 800 ? 96 : 128;
|
|
367
|
+
const videoKbps = Math.max(200, kbps - audioKbps);
|
|
368
|
+
return [
|
|
369
|
+
"-c:v", "libx264",
|
|
370
|
+
"-preset", "veryfast",
|
|
371
|
+
"-pix_fmt", "yuv420p",
|
|
372
|
+
// -2 keeps the aspect ratio and an even height, which H.264 requires.
|
|
373
|
+
// The min() never enlarges: a 480p source asked for 720p stays 480p.
|
|
374
|
+
"-vf", `scale='min(${widthFor(kbps)},iw)':-2`,
|
|
375
|
+
"-b:v", `${videoKbps}k`,
|
|
376
|
+
// A ceiling rather than an average, because an average that spikes is a
|
|
377
|
+
// stall on a link this size. The buffer is one second of it.
|
|
378
|
+
"-maxrate", `${videoKbps}k`,
|
|
379
|
+
"-bufsize", `${videoKbps}k`,
|
|
380
|
+
"-c:a", "aac",
|
|
381
|
+
"-b:a", `${audioKbps}k`,
|
|
382
|
+
"-ac", "2",
|
|
383
|
+
"-f", "mp4",
|
|
384
|
+
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
|
385
|
+
];
|
|
386
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name other people see.
|
|
3
|
+
*
|
|
4
|
+
* An account is keyed on an email address, because that is what the auth module
|
|
5
|
+
* authenticates. An address is a credential and a way to reach somebody, and it
|
|
6
|
+
* is not a name: putting it in a directory listing, an invite or a subdomain
|
|
7
|
+
* publishes something the account holder gave us to log in with.
|
|
8
|
+
*
|
|
9
|
+
* So there are two names. The address stays private and does the linking, and a
|
|
10
|
+
* handle is the one that appears in front of strangers. They are never the same
|
|
11
|
+
* field and never travel in the same response.
|
|
12
|
+
*/
|
|
13
|
+
import type { Queryable } from "./follows.ts";
|
|
14
|
+
/**
|
|
15
|
+
* What a handle may be.
|
|
16
|
+
*
|
|
17
|
+
* It ends up in a URL, a subdomain and a text message, so it is the intersection
|
|
18
|
+
* of what all three tolerate: lowercase letters, digits and hyphens, not
|
|
19
|
+
* starting or ending with one. Two to thirty characters, because a subdomain
|
|
20
|
+
* label cannot exceed sixty-three and nobody types thirty.
|
|
21
|
+
*/
|
|
22
|
+
export declare function cleanHandle(value: unknown): string;
|
|
23
|
+
export declare function isReserved(handle: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* A handle for somebody who has not chosen one.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately not derived from the address. "anthony@profullstack.com" turning
|
|
28
|
+
* into "anthony" is exactly the leak this whole file exists to avoid, and it
|
|
29
|
+
* would be a leak nobody noticed until it was already in a directory listing.
|
|
30
|
+
*/
|
|
31
|
+
export declare function anonymousHandle(random: (size: number) => Uint8Array): string;
|
|
32
|
+
export declare class Handles {
|
|
33
|
+
private readonly db;
|
|
34
|
+
private ready;
|
|
35
|
+
constructor(db: Queryable);
|
|
36
|
+
private ensure;
|
|
37
|
+
of(userId: string): Promise<string>;
|
|
38
|
+
/** Who holds this handle, so a listing can name somebody without their address. */
|
|
39
|
+
holder(handle: string): Promise<string>;
|
|
40
|
+
/**
|
|
41
|
+
* Claim one. Answers the reason it could not be taken rather than a boolean,
|
|
42
|
+
* because "that is already somebody's" and "that is not a name" are different
|
|
43
|
+
* things to tell a person.
|
|
44
|
+
*/
|
|
45
|
+
claim(userId: string, wanted: unknown): Promise<{
|
|
46
|
+
handle: string;
|
|
47
|
+
error: string;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
package/dist/handles.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const TABLE = "nixamp_handles";
|
|
2
|
+
const SCHEMA = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
4
|
+
user_id TEXT PRIMARY KEY,
|
|
5
|
+
handle TEXT NOT NULL,
|
|
6
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
7
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
8
|
+
);
|
|
9
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
|
|
10
|
+
`;
|
|
11
|
+
/**
|
|
12
|
+
* What a handle may be.
|
|
13
|
+
*
|
|
14
|
+
* It ends up in a URL, a subdomain and a text message, so it is the intersection
|
|
15
|
+
* of what all three tolerate: lowercase letters, digits and hyphens, not
|
|
16
|
+
* starting or ending with one. Two to thirty characters, because a subdomain
|
|
17
|
+
* label cannot exceed sixty-three and nobody types thirty.
|
|
18
|
+
*/
|
|
19
|
+
export function cleanHandle(value) {
|
|
20
|
+
if (typeof value !== "string")
|
|
21
|
+
return "";
|
|
22
|
+
const wanted = value.trim().toLowerCase();
|
|
23
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(wanted))
|
|
24
|
+
return "";
|
|
25
|
+
// Doubled hyphens are how punycode marks an encoded label, so a handle with
|
|
26
|
+
// one in it can collide with an internationalised domain.
|
|
27
|
+
return wanted.includes("--") ? "" : wanted;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Names nobody may take, because a subdomain carrying one would impersonate the
|
|
31
|
+
* service or reach a machine we run.
|
|
32
|
+
*/
|
|
33
|
+
const RESERVED = new Set([
|
|
34
|
+
"www", "api", "admin", "root", "nixamp", "mail", "smtp", "imap", "ns1", "ns2",
|
|
35
|
+
"static", "cdn", "assets", "app", "dev", "staging", "test", "support", "help",
|
|
36
|
+
"status", "blog", "directory", "login", "signup", "account", "settings", "me",
|
|
37
|
+
]);
|
|
38
|
+
export function isReserved(handle) {
|
|
39
|
+
return RESERVED.has(handle);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A handle for somebody who has not chosen one.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately not derived from the address. "anthony@profullstack.com" turning
|
|
45
|
+
* into "anthony" is exactly the leak this whole file exists to avoid, and it
|
|
46
|
+
* would be a leak nobody noticed until it was already in a directory listing.
|
|
47
|
+
*/
|
|
48
|
+
export function anonymousHandle(random) {
|
|
49
|
+
const bytes = random(4);
|
|
50
|
+
let out = "";
|
|
51
|
+
for (const byte of bytes)
|
|
52
|
+
out += byte.toString(16).padStart(2, "0");
|
|
53
|
+
return `nixamp-${out}`;
|
|
54
|
+
}
|
|
55
|
+
export class Handles {
|
|
56
|
+
db;
|
|
57
|
+
ready = null;
|
|
58
|
+
constructor(db) {
|
|
59
|
+
this.db = db;
|
|
60
|
+
}
|
|
61
|
+
async ensure() {
|
|
62
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
63
|
+
await this.ready;
|
|
64
|
+
}
|
|
65
|
+
async of(userId) {
|
|
66
|
+
await this.ensure();
|
|
67
|
+
const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
68
|
+
return rows[0] ? String(rows[0]["handle"] ?? "") : "";
|
|
69
|
+
}
|
|
70
|
+
/** Who holds this handle, so a listing can name somebody without their address. */
|
|
71
|
+
async holder(handle) {
|
|
72
|
+
const wanted = cleanHandle(handle);
|
|
73
|
+
if (!wanted)
|
|
74
|
+
return "";
|
|
75
|
+
await this.ensure();
|
|
76
|
+
const { rows } = await this.db.query(`SELECT user_id FROM ${TABLE} WHERE lower(handle) = $1`, [wanted]);
|
|
77
|
+
return rows[0] ? String(rows[0]["user_id"] ?? "") : "";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Claim one. Answers the reason it could not be taken rather than a boolean,
|
|
81
|
+
* because "that is already somebody's" and "that is not a name" are different
|
|
82
|
+
* things to tell a person.
|
|
83
|
+
*/
|
|
84
|
+
async claim(userId, wanted) {
|
|
85
|
+
const handle = cleanHandle(wanted);
|
|
86
|
+
if (!handle) {
|
|
87
|
+
return { handle: "", error: "letters, digits and hyphens, 2 to 30 characters" };
|
|
88
|
+
}
|
|
89
|
+
if (isReserved(handle))
|
|
90
|
+
return { handle: "", error: "that one is reserved" };
|
|
91
|
+
await this.ensure();
|
|
92
|
+
const taken = await this.holder(handle);
|
|
93
|
+
if (taken && taken !== userId)
|
|
94
|
+
return { handle: "", error: "somebody already has that one" };
|
|
95
|
+
await this.db.query(`INSERT INTO ${TABLE} (user_id, handle) VALUES ($1, $2)
|
|
96
|
+
ON CONFLICT (user_id) DO UPDATE SET handle = EXCLUDED.handle, updated_at = NOW()`, [userId, handle]);
|
|
97
|
+
return { handle, error: "" };
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/invite.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking somebody to watch, when that somebody is not technical.
|
|
3
|
+
*
|
|
4
|
+
* A share link is a URL with a key in it, which is fine for the person who
|
|
5
|
+
* runs the server and useless as a thing to text your mother. An invite is the
|
|
6
|
+
* three ways in, written as a sentence: a link that opens a player, a phone
|
|
7
|
+
* number, and the code to key once it answers.
|
|
8
|
+
*
|
|
9
|
+
* The sender is signed in, because sending is an action with a cost: a text
|
|
10
|
+
* message is money and somebody's phone. The recipient signs in too, but only
|
|
11
|
+
* once and only at the far end of a single click, because a stream can ask to
|
|
12
|
+
* be paid for -- x402 starts charging past five listeners -- and there is
|
|
13
|
+
* nobody to charge without an account. The dial-in path is the exception and
|
|
14
|
+
* stays open to anybody, since a phone call cannot sign in to anything.
|
|
15
|
+
*/
|
|
16
|
+
/** Where the phone line answers, and what to key when it does. */
|
|
17
|
+
export interface Invite {
|
|
18
|
+
/** What the stream is called, as the recipient will see it. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** A link that opens a player on this stream, listen only. */
|
|
21
|
+
link: string;
|
|
22
|
+
/** The phone number, when this stream is one the line knows about. */
|
|
23
|
+
phone: string;
|
|
24
|
+
/** The six digits that reach this stream, when it has been published. */
|
|
25
|
+
code: string;
|
|
26
|
+
}
|
|
27
|
+
/** Looks like a phone number rather than an address. */
|
|
28
|
+
export declare function isPhone(value: string): boolean;
|
|
29
|
+
/** Looks like somewhere an email could arrive. */
|
|
30
|
+
export declare function isEmail(value: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The message itself.
|
|
33
|
+
*
|
|
34
|
+
* Short, because it is going into a text message, and ordered by how likely
|
|
35
|
+
* each way in is to work for the person reading it. The link first: most
|
|
36
|
+
* people have a browser in their hand. The phone last, because it is the one
|
|
37
|
+
* that needs no browser at all and is therefore the fallback that never fails.
|
|
38
|
+
*/
|
|
39
|
+
export declare function inviteText(invite: Invite): string;
|
|
40
|
+
/** The same thing as a subject line, for the surface that wants one. */
|
|
41
|
+
export declare function inviteSubject(invite: Invite): string;
|
|
42
|
+
/**
|
|
43
|
+
* A link that opens a player on this stream.
|
|
44
|
+
*
|
|
45
|
+
* Sent through nixamp.com when the stream is https, because that page is a
|
|
46
|
+
* player anybody can already open and reaches this stream with `?url=`. An
|
|
47
|
+
* http stream is sent as its own address instead: a browser refuses every
|
|
48
|
+
* request from an https page to an http one, so routing it through nixamp.com
|
|
49
|
+
* would produce a link that cannot work, which is worse than a plainer one
|
|
50
|
+
* that does.
|
|
51
|
+
*/
|
|
52
|
+
export declare function watchLink(streamUrl: string, site: string): string;
|
package/dist/invite.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking somebody to watch, when that somebody is not technical.
|
|
3
|
+
*
|
|
4
|
+
* A share link is a URL with a key in it, which is fine for the person who
|
|
5
|
+
* runs the server and useless as a thing to text your mother. An invite is the
|
|
6
|
+
* three ways in, written as a sentence: a link that opens a player, a phone
|
|
7
|
+
* number, and the code to key once it answers.
|
|
8
|
+
*
|
|
9
|
+
* The sender is signed in, because sending is an action with a cost: a text
|
|
10
|
+
* message is money and somebody's phone. The recipient signs in too, but only
|
|
11
|
+
* once and only at the far end of a single click, because a stream can ask to
|
|
12
|
+
* be paid for -- x402 starts charging past five listeners -- and there is
|
|
13
|
+
* nobody to charge without an account. The dial-in path is the exception and
|
|
14
|
+
* stays open to anybody, since a phone call cannot sign in to anything.
|
|
15
|
+
*/
|
|
16
|
+
/** Looks like a phone number rather than an address. */
|
|
17
|
+
export function isPhone(value) {
|
|
18
|
+
return /^\+?[\d\s().-]{7,20}$/.test(value.trim()) && /\d{7}/.test(value.replace(/\D/g, ""));
|
|
19
|
+
}
|
|
20
|
+
/** Looks like somewhere an email could arrive. */
|
|
21
|
+
export function isEmail(value) {
|
|
22
|
+
return /^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(value.trim());
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The message itself.
|
|
26
|
+
*
|
|
27
|
+
* Short, because it is going into a text message, and ordered by how likely
|
|
28
|
+
* each way in is to work for the person reading it. The link first: most
|
|
29
|
+
* people have a browser in their hand. The phone last, because it is the one
|
|
30
|
+
* that needs no browser at all and is therefore the fallback that never fails.
|
|
31
|
+
*/
|
|
32
|
+
export function inviteText(invite) {
|
|
33
|
+
const lines = [`${invite.name} is streaming.`, "", `Watch: ${invite.link}`];
|
|
34
|
+
if (invite.phone && invite.code) {
|
|
35
|
+
lines.push("", `Or call ${invite.phone} and key ${invite.code} to listen.`);
|
|
36
|
+
}
|
|
37
|
+
return lines.join("\n");
|
|
38
|
+
}
|
|
39
|
+
/** The same thing as a subject line, for the surface that wants one. */
|
|
40
|
+
export function inviteSubject(invite) {
|
|
41
|
+
return `${invite.name} is streaming`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A link that opens a player on this stream.
|
|
45
|
+
*
|
|
46
|
+
* Sent through nixamp.com when the stream is https, because that page is a
|
|
47
|
+
* player anybody can already open and reaches this stream with `?url=`. An
|
|
48
|
+
* http stream is sent as its own address instead: a browser refuses every
|
|
49
|
+
* request from an https page to an http one, so routing it through nixamp.com
|
|
50
|
+
* would produce a link that cannot work, which is worse than a plainer one
|
|
51
|
+
* that does.
|
|
52
|
+
*/
|
|
53
|
+
export function watchLink(streamUrl, site) {
|
|
54
|
+
const bare = streamUrl.replace(/\/+$/, "");
|
|
55
|
+
if (!bare.startsWith("https://"))
|
|
56
|
+
return bare;
|
|
57
|
+
return `${site.replace(/\/+$/, "")}/?url=${encodeURIComponent(bare)}`;
|
|
58
|
+
}
|
package/dist/playlist.d.ts
CHANGED
|
@@ -10,6 +10,19 @@ export declare function findAudio(root: string): string[];
|
|
|
10
10
|
* ffmpeg is left to do what it is good at.
|
|
11
11
|
*/
|
|
12
12
|
export declare function readPlaylist(source: string): Promise<Entry[]>;
|
|
13
|
+
/**
|
|
14
|
+
* The audio linked from a directory listing a web server generated.
|
|
15
|
+
*
|
|
16
|
+
* A seedbox or a plain Apache with autoindex on serves a folder as an HTML page
|
|
17
|
+
* of relative links. Handed one of those, nixamp used to make a single track of
|
|
18
|
+
* the page itself and give it to ffmpeg, which is asked to decode HTML and says
|
|
19
|
+
* so in a way nobody reads. It is a folder; it should behave like one.
|
|
20
|
+
*
|
|
21
|
+
* Not recursive, deliberately: one page is one album, the subdirectory links are
|
|
22
|
+
* on it, and walking a stranger's whole tree from a text box is a different and
|
|
23
|
+
* much larger thing to ask for.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readRemoteIndex(source: string, send?: typeof fetch): Promise<Entry[]>;
|
|
13
26
|
/**
|
|
14
27
|
* Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
|
|
15
28
|
* or a URL to any of those.
|
|
@@ -38,5 +51,7 @@ export declare function loadPlaylist(tools: Tools, root: string, probeTags?: boo
|
|
|
38
51
|
* ffprobe rather than for the whole library, and the tagging still finishes in
|
|
39
52
|
* about the time it did.
|
|
40
53
|
*/
|
|
41
|
-
export declare function loadTagged(tools: Tools, source: string
|
|
54
|
+
export declare function loadTagged(tools: Tools, source: string,
|
|
55
|
+
/** Injected by the test, which must not depend on ffprobe being installed. */
|
|
56
|
+
probeOne?: (tools: Tools, path: string) => Promise<Track>): Promise<Track[]>;
|
|
42
57
|
export declare function displayName(track: Track): string;
|
package/dist/playlist.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The playlist: audio found on disk or named by a playlist, in a stable order. */
|
|
2
2
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { probe } from "./audio.js";
|
|
4
|
+
import { probe, probeAsync } from "./audio.js";
|
|
5
5
|
import { isHls, isPlaylistFile, isRemote, nameOf, parseM3u, parsePls, } from "./sources.js";
|
|
6
6
|
export const AUDIO_EXTENSIONS = new Set([
|
|
7
7
|
".mp3", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac",
|
|
@@ -81,6 +81,69 @@ export async function readPlaylist(source) {
|
|
|
81
81
|
const entries = /\.pls$/i.test(source) ? parsePls(text, source) : parseM3u(text, source);
|
|
82
82
|
return entries;
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The audio linked from a directory listing a web server generated.
|
|
86
|
+
*
|
|
87
|
+
* A seedbox or a plain Apache with autoindex on serves a folder as an HTML page
|
|
88
|
+
* of relative links. Handed one of those, nixamp used to make a single track of
|
|
89
|
+
* the page itself and give it to ffmpeg, which is asked to decode HTML and says
|
|
90
|
+
* so in a way nobody reads. It is a folder; it should behave like one.
|
|
91
|
+
*
|
|
92
|
+
* Not recursive, deliberately: one page is one album, the subdirectory links are
|
|
93
|
+
* on it, and walking a stranger's whole tree from a text box is a different and
|
|
94
|
+
* much larger thing to ask for.
|
|
95
|
+
*/
|
|
96
|
+
export async function readRemoteIndex(source, send = fetch) {
|
|
97
|
+
let answer;
|
|
98
|
+
try {
|
|
99
|
+
answer = await send(source, {
|
|
100
|
+
redirect: "follow",
|
|
101
|
+
headers: { accept: "text/html,*/*" },
|
|
102
|
+
signal: AbortSignal.timeout(15_000),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
if (!answer.ok)
|
|
109
|
+
return [];
|
|
110
|
+
// Anything that is not a page is the thing itself, and the caller plays it.
|
|
111
|
+
if (!/^text\/html/i.test(answer.headers.get("content-type") ?? ""))
|
|
112
|
+
return [];
|
|
113
|
+
const html = await answer.text().catch(() => "");
|
|
114
|
+
const found = [];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const match of html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
|
117
|
+
const href = match[1];
|
|
118
|
+
// The sort links an index puts at the top of every column, and anchors.
|
|
119
|
+
if (!href || href.startsWith("?") || href.startsWith("#"))
|
|
120
|
+
continue;
|
|
121
|
+
let url;
|
|
122
|
+
try {
|
|
123
|
+
// Relative to the page, which is how an index writes them.
|
|
124
|
+
url = new URL(href.replace(/&/g, "&"), source);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!isAudio(url.pathname))
|
|
130
|
+
continue;
|
|
131
|
+
const link = url.toString();
|
|
132
|
+
if (seen.has(link))
|
|
133
|
+
continue;
|
|
134
|
+
seen.add(link);
|
|
135
|
+
found.push({
|
|
136
|
+
source: link,
|
|
137
|
+
// The name as a person wrote it, not as a URL spells it.
|
|
138
|
+
title: decodeURIComponent(url.pathname.split("/").pop() ?? link),
|
|
139
|
+
duration: 0,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Server order is by whatever column the index sorted on; by name is what
|
|
143
|
+
// somebody handing over an album meant.
|
|
144
|
+
found.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }));
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
84
147
|
/**
|
|
85
148
|
* Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
|
|
86
149
|
* or a URL to any of those.
|
|
@@ -104,10 +167,19 @@ export async function loadSource(tools, source, probeTags = true) {
|
|
|
104
167
|
? { ...probe(tools, entry.source), title: entry.title || probe(tools, entry.source).title }
|
|
105
168
|
: bare(entry));
|
|
106
169
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
170
|
+
if (isRemote(source)) {
|
|
171
|
+
// A URL that names no file is probably a folder, and a folder served over
|
|
172
|
+
// http is a page of links. Asked only when it could be one: a stream URL
|
|
173
|
+
// must not pay for a fetch that will tell us nothing.
|
|
174
|
+
if (!isAudio(new URL(source).pathname)) {
|
|
175
|
+
const listed = await readRemoteIndex(source);
|
|
176
|
+
if (listed.length > 0)
|
|
177
|
+
return listed.map(bare);
|
|
178
|
+
}
|
|
179
|
+
// A bare URL is one remote thing to play. Whether it is a song or a live
|
|
180
|
+
// stream is ffmpeg's problem, and it is good at it.
|
|
110
181
|
return [bare({ source, title: nameOf(source), duration: 0 })];
|
|
182
|
+
}
|
|
111
183
|
return loadPlaylist(tools, source, probeTags);
|
|
112
184
|
}
|
|
113
185
|
/**
|
|
@@ -135,15 +207,20 @@ export function loadPlaylist(tools, root, probeTags = true) {
|
|
|
135
207
|
* ffprobe rather than for the whole library, and the tagging still finishes in
|
|
136
208
|
* about the time it did.
|
|
137
209
|
*/
|
|
138
|
-
export async function loadTagged(tools, source
|
|
210
|
+
export async function loadTagged(tools, source,
|
|
211
|
+
/** Injected by the test, which must not depend on ffprobe being installed. */
|
|
212
|
+
probeOne = probeAsync) {
|
|
139
213
|
// A URL is one thing and is never probed; a playlist carries its own titles.
|
|
140
214
|
if (isRemote(source) || isPlaylistFile(source))
|
|
141
215
|
return loadSource(tools, source, true);
|
|
142
216
|
const paths = findAudio(source);
|
|
143
217
|
const tracks = [];
|
|
144
218
|
for (const path of paths) {
|
|
145
|
-
|
|
146
|
-
|
|
219
|
+
// Awaiting a child process, not blocking on one. Yielding between files
|
|
220
|
+
// was not enough: each spawnSync still stopped everything for as long as
|
|
221
|
+
// one ffprobe took, which on a large file is long enough to strangle a
|
|
222
|
+
// stream being served at the same time.
|
|
223
|
+
tracks.push(await probeOne(tools, path));
|
|
147
224
|
}
|
|
148
225
|
return tracks;
|
|
149
226
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast
|
|
|
4
4
|
import { Ingest } from "./ingest.ts";
|
|
5
5
|
import { Channels } from "./channels.ts";
|
|
6
6
|
import { Accounts } from "./accounts.ts";
|
|
7
|
+
import { Handles } from "./handles.ts";
|
|
7
8
|
import { Servers } from "./servers.ts";
|
|
8
9
|
import { SignIn } from "./oauth.ts";
|
|
9
10
|
import { Owner } from "./owner.ts";
|
|
@@ -269,6 +270,8 @@ export interface HandlerOptions {
|
|
|
269
270
|
signIn?: SignIn;
|
|
270
271
|
/** The servers each account runs, on the instance that keeps accounts. */
|
|
271
272
|
servers?: Servers;
|
|
273
|
+
/** The name other people see, which is never the address they signed up with. */
|
|
274
|
+
handles?: Handles;
|
|
272
275
|
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
273
276
|
secureCookies?: boolean;
|
|
274
277
|
/** A certificate and key in PEM, when this server is to speak https itself. */
|