nixamp 0.6.0 → 0.6.4
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/admin.d.ts +13 -0
- package/dist/admin.js +27 -3
- package/dist/audio.d.ts +24 -1
- package/dist/audio.js +116 -1
- package/dist/invite.d.ts +52 -0
- package/dist/invite.js +58 -0
- package/dist/playlist.d.ts +3 -1
- package/dist/playlist.js +9 -4
- package/dist/server.d.ts +6 -0
- package/dist/server.js +20 -3
- package/package.json +1 -1
- package/src/admin.ts +26 -2
- package/src/audio.ts +120 -1
- package/src/invite.ts +74 -0
- package/src/playlist.ts +12 -4
- package/src/server.ts +27 -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/admin.d.ts
CHANGED
|
@@ -46,6 +46,19 @@ export interface AdminOptions {
|
|
|
46
46
|
export declare function resolveTarget(argv: string[]): AdminOptions;
|
|
47
47
|
/** Seconds as something a person reads at a glance. */
|
|
48
48
|
export declare function since(ms: number): string;
|
|
49
|
+
/**
|
|
50
|
+
* What a keypress contributes to a field being typed into.
|
|
51
|
+
*
|
|
52
|
+
* A paste is one event carrying the whole string, not a burst of single
|
|
53
|
+
* characters, so a handler that only accepted `key.length === 1` accepted
|
|
54
|
+
* nothing at all from a paste -- which is how you find you cannot put a URL in
|
|
55
|
+
* the box by any means except typing it out.
|
|
56
|
+
*
|
|
57
|
+
* The ambiguity is real and unavoidable: a pasted word of bare letters is
|
|
58
|
+
* indistinguishable from a key name, and loses. A URL or a path never is,
|
|
59
|
+
* because neither is spelled with letters alone.
|
|
60
|
+
*/
|
|
61
|
+
export declare function typed(key: string): string;
|
|
49
62
|
export declare function bytes(value: number): string;
|
|
50
63
|
export declare function admin(argv: string[]): Promise<void>;
|
|
51
64
|
export interface View {
|
package/dist/admin.js
CHANGED
|
@@ -44,6 +44,31 @@ export function since(ms) {
|
|
|
44
44
|
return `${hours}h ${minutes % 60}m`;
|
|
45
45
|
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* A key name rather than something somebody typed: "up", "f7", "ctrl+c".
|
|
49
|
+
* Letters and digits only, so anything with a colon or a slash in it is text.
|
|
50
|
+
*/
|
|
51
|
+
const NAMED_KEY = /^(?:[a-z]+\d*|(?:ctrl|alt|shift|meta)\+.+)$/;
|
|
52
|
+
/**
|
|
53
|
+
* What a keypress contributes to a field being typed into.
|
|
54
|
+
*
|
|
55
|
+
* A paste is one event carrying the whole string, not a burst of single
|
|
56
|
+
* characters, so a handler that only accepted `key.length === 1` accepted
|
|
57
|
+
* nothing at all from a paste -- which is how you find you cannot put a URL in
|
|
58
|
+
* the box by any means except typing it out.
|
|
59
|
+
*
|
|
60
|
+
* The ambiguity is real and unavoidable: a pasted word of bare letters is
|
|
61
|
+
* indistinguishable from a key name, and loses. A URL or a path never is,
|
|
62
|
+
* because neither is spelled with letters alone.
|
|
63
|
+
*/
|
|
64
|
+
export function typed(key) {
|
|
65
|
+
if (key.length === 1)
|
|
66
|
+
return key >= " " && key !== "\u007f" ? key : "";
|
|
67
|
+
if (NAMED_KEY.test(key))
|
|
68
|
+
return "";
|
|
69
|
+
// A paste. Control characters and newlines are not part of an address.
|
|
70
|
+
return key.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
71
|
+
}
|
|
47
72
|
export function bytes(value) {
|
|
48
73
|
const units = ["B", "KiB", "MiB", "GiB"];
|
|
49
74
|
let n = value;
|
|
@@ -109,9 +134,8 @@ export async function admin(argv) {
|
|
|
109
134
|
}
|
|
110
135
|
else if (key === "backspace")
|
|
111
136
|
restreaming = restreaming.slice(0, -1);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
restreaming += key;
|
|
137
|
+
else
|
|
138
|
+
restreaming += typed(key);
|
|
115
139
|
app.invalidate();
|
|
116
140
|
return;
|
|
117
141
|
}
|
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
|
+
}
|
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
|
@@ -38,5 +38,7 @@ export declare function loadPlaylist(tools: Tools, root: string, probeTags?: boo
|
|
|
38
38
|
* ffprobe rather than for the whole library, and the tagging still finishes in
|
|
39
39
|
* about the time it did.
|
|
40
40
|
*/
|
|
41
|
-
export declare function loadTagged(tools: Tools, source: string
|
|
41
|
+
export declare function loadTagged(tools: Tools, source: string,
|
|
42
|
+
/** Injected by the test, which must not depend on ffprobe being installed. */
|
|
43
|
+
probeOne?: (tools: Tools, path: string) => Promise<Track>): Promise<Track[]>;
|
|
42
44
|
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",
|
|
@@ -135,15 +135,20 @@ export function loadPlaylist(tools, root, probeTags = true) {
|
|
|
135
135
|
* ffprobe rather than for the whole library, and the tagging still finishes in
|
|
136
136
|
* about the time it did.
|
|
137
137
|
*/
|
|
138
|
-
export async function loadTagged(tools, source
|
|
138
|
+
export async function loadTagged(tools, source,
|
|
139
|
+
/** Injected by the test, which must not depend on ffprobe being installed. */
|
|
140
|
+
probeOne = probeAsync) {
|
|
139
141
|
// A URL is one thing and is never probed; a playlist carries its own titles.
|
|
140
142
|
if (isRemote(source) || isPlaylistFile(source))
|
|
141
143
|
return loadSource(tools, source, true);
|
|
142
144
|
const paths = findAudio(source);
|
|
143
145
|
const tracks = [];
|
|
144
146
|
for (const path of paths) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
// Awaiting a child process, not blocking on one. Yielding between files
|
|
148
|
+
// was not enough: each spawnSync still stopped everything for as long as
|
|
149
|
+
// one ffprobe took, which on a large file is long enough to strangle a
|
|
150
|
+
// stream being served at the same time.
|
|
151
|
+
tracks.push(await probeOne(tools, path));
|
|
147
152
|
}
|
|
148
153
|
return tracks;
|
|
149
154
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -239,6 +239,12 @@ export interface HandlerOptions {
|
|
|
239
239
|
* imported so the handler stays a plain function of a request.
|
|
240
240
|
*/
|
|
241
241
|
load: (source: string) => Promise<Track[]>;
|
|
242
|
+
/**
|
|
243
|
+
* The same source, with its tags, read without holding the event loop. Called
|
|
244
|
+
* after `load` and never awaited: the titles arrive into a player that is
|
|
245
|
+
* already playing.
|
|
246
|
+
*/
|
|
247
|
+
tag?: (source: string) => Promise<Track[]>;
|
|
242
248
|
/**
|
|
243
249
|
* The public directory, on the instance that hosts one. Only nixamp.com
|
|
244
250
|
* passes this; a nixamp on your laptop is a publisher, not a registry.
|
package/dist/server.js
CHANGED
|
@@ -1623,6 +1623,15 @@ export function createHandler(engine, options) {
|
|
|
1623
1623
|
return;
|
|
1624
1624
|
}
|
|
1625
1625
|
engine.replace(tracks, source);
|
|
1626
|
+
// Names now, tags later, here as much as at startup: re-streaming a
|
|
1627
|
+
// directory of five thousand files used to read every tag before it
|
|
1628
|
+
// answered, with the event loop held the whole time.
|
|
1629
|
+
if (options.tag) {
|
|
1630
|
+
void options
|
|
1631
|
+
.tag(source)
|
|
1632
|
+
.then((tagged) => engine.retag(tagged, source))
|
|
1633
|
+
.catch(() => { });
|
|
1634
|
+
}
|
|
1626
1635
|
json(response, 200, engine.snapshot());
|
|
1627
1636
|
}
|
|
1628
1637
|
catch (error) {
|
|
@@ -1646,7 +1655,12 @@ export function createHandler(engine, options) {
|
|
|
1646
1655
|
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
1647
1656
|
// and transcoding gives it up, but an unseekable film beats a silent
|
|
1648
1657
|
// one -- and the seekable formats are untouched.
|
|
1649
|
-
|
|
1658
|
+
// A ceiling the caller asked for, because only the caller knows what its
|
|
1659
|
+
// link can carry. Capped at both ends: nothing below 200k is watchable,
|
|
1660
|
+
// and above 20 megabits the original was always the better answer.
|
|
1661
|
+
const asked = Number(url.searchParams.get("kbps") ?? "");
|
|
1662
|
+
const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
|
|
1663
|
+
if (playsInBrowser(file) && capKbps === 0) {
|
|
1650
1664
|
sendFile(request, response, file);
|
|
1651
1665
|
}
|
|
1652
1666
|
else if (hasPicture(file)) {
|
|
@@ -1654,7 +1668,7 @@ export function createHandler(engine, options) {
|
|
|
1654
1668
|
// soundtrack over a blank panel; what ffprobe finds inside decides how
|
|
1655
1669
|
// little work it takes to keep the picture.
|
|
1656
1670
|
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
1657
|
-
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
|
|
1671
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
|
|
1658
1672
|
}
|
|
1659
1673
|
else {
|
|
1660
1674
|
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
@@ -2234,7 +2248,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2234
2248
|
ffmpeg: tools.ffmpeg,
|
|
2235
2249
|
ffprobe: tools.ffprobe,
|
|
2236
2250
|
...(tls ? { tls } : {}),
|
|
2237
|
-
|
|
2251
|
+
// Untagged, so a directory of five thousand files answers at once; the
|
|
2252
|
+
// tags follow through `tag` below.
|
|
2253
|
+
load: (next) => loadSource(tools, next, false),
|
|
2254
|
+
tag: (next) => loadTagged(tools, next),
|
|
2238
2255
|
...(directory ? { directory } : {}),
|
|
2239
2256
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
2240
2257
|
...(partyLine ? { partyLine } : {}),
|
package/package.json
CHANGED
package/src/admin.ts
CHANGED
|
@@ -79,6 +79,31 @@ export function since(ms: number): string {
|
|
|
79
79
|
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* A key name rather than something somebody typed: "up", "f7", "ctrl+c".
|
|
84
|
+
* Letters and digits only, so anything with a colon or a slash in it is text.
|
|
85
|
+
*/
|
|
86
|
+
const NAMED_KEY = /^(?:[a-z]+\d*|(?:ctrl|alt|shift|meta)\+.+)$/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What a keypress contributes to a field being typed into.
|
|
90
|
+
*
|
|
91
|
+
* A paste is one event carrying the whole string, not a burst of single
|
|
92
|
+
* characters, so a handler that only accepted `key.length === 1` accepted
|
|
93
|
+
* nothing at all from a paste -- which is how you find you cannot put a URL in
|
|
94
|
+
* the box by any means except typing it out.
|
|
95
|
+
*
|
|
96
|
+
* The ambiguity is real and unavoidable: a pasted word of bare letters is
|
|
97
|
+
* indistinguishable from a key name, and loses. A URL or a path never is,
|
|
98
|
+
* because neither is spelled with letters alone.
|
|
99
|
+
*/
|
|
100
|
+
export function typed(key: string): string {
|
|
101
|
+
if (key.length === 1) return key >= " " && key !== "\u007f" ? key : "";
|
|
102
|
+
if (NAMED_KEY.test(key)) return "";
|
|
103
|
+
// A paste. Control characters and newlines are not part of an address.
|
|
104
|
+
return key.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
export function bytes(value: number): string {
|
|
83
108
|
const units = ["B", "KiB", "MiB", "GiB"];
|
|
84
109
|
let n = value;
|
|
@@ -142,8 +167,7 @@ export async function admin(argv: string[]): Promise<void> {
|
|
|
142
167
|
restreaming = "";
|
|
143
168
|
if (url) void restream(target, headers, url).then(() => refresh());
|
|
144
169
|
} else if (key === "backspace") restreaming = restreaming.slice(0, -1);
|
|
145
|
-
|
|
146
|
-
else if (key.length === 1) restreaming += key;
|
|
170
|
+
else restreaming += typed(key);
|
|
147
171
|
app.invalidate();
|
|
148
172
|
return;
|
|
149
173
|
}
|
package/src/audio.ts
CHANGED
|
@@ -232,6 +232,82 @@ export function formatTime(seconds: number): string {
|
|
|
232
232
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* The same tags, read without blocking anything.
|
|
237
|
+
*
|
|
238
|
+
* `probe` is spawnSync, and 0.5.5 tried to fix the tagging pass by yielding
|
|
239
|
+
* between files. That is not enough: each individual call still stops the
|
|
240
|
+
* process for as long as one ffprobe takes, and on a large file over a slow
|
|
241
|
+
* disk that is hundreds of milliseconds. Yield, block, yield, block, and a
|
|
242
|
+
* server delivers a stream in slivers -- measured at 357 KB/s on a machine
|
|
243
|
+
* whose disk reads at 6.5 MB/s and whose link runs at 1.4 Gbps.
|
|
244
|
+
*
|
|
245
|
+
* ffprobe still costs what it costs. It just costs it in a child process now,
|
|
246
|
+
* which is where that work belongs.
|
|
247
|
+
*/
|
|
248
|
+
export async function probeAsync(tools: Tools, path: string): Promise<Track> {
|
|
249
|
+
const [cmd, ...rest] = tools.ffprobe;
|
|
250
|
+
const fallback: Track = {
|
|
251
|
+
path,
|
|
252
|
+
title: path.split("/").pop() ?? path,
|
|
253
|
+
artist: "",
|
|
254
|
+
album: "",
|
|
255
|
+
duration: 0,
|
|
256
|
+
};
|
|
257
|
+
if (!cmd) return fallback;
|
|
258
|
+
|
|
259
|
+
return new Promise<Track>((done) => {
|
|
260
|
+
const child = spawn(
|
|
261
|
+
cmd,
|
|
262
|
+
[
|
|
263
|
+
...rest,
|
|
264
|
+
"-v", "quiet", "-print_format", "json",
|
|
265
|
+
"-show_format", "-show_entries", "format_tags=title,artist,album",
|
|
266
|
+
path,
|
|
267
|
+
],
|
|
268
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
269
|
+
);
|
|
270
|
+
let out = "";
|
|
271
|
+
// A file that will not answer must not hold a place in the queue for ever.
|
|
272
|
+
const giveUp = setTimeout(() => child.kill("SIGKILL"), 20_000);
|
|
273
|
+
giveUp.unref?.();
|
|
274
|
+
|
|
275
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
276
|
+
if (out.length < 4 * 1024 * 1024) out += chunk.toString("utf8");
|
|
277
|
+
});
|
|
278
|
+
child.on("error", () => {
|
|
279
|
+
clearTimeout(giveUp);
|
|
280
|
+
done(fallback);
|
|
281
|
+
});
|
|
282
|
+
child.on("close", (code) => {
|
|
283
|
+
clearTimeout(giveUp);
|
|
284
|
+
if (code !== 0) return done(fallback);
|
|
285
|
+
done(readTags(out, fallback));
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The tags out of ffprobe's JSON, or the filename when it said nothing useful. */
|
|
291
|
+
function readTags(stdout: string, fallback: Track): Track {
|
|
292
|
+
try {
|
|
293
|
+
const parsed = JSON.parse(stdout) as {
|
|
294
|
+
format?: { duration?: string; tags?: Record<string, string> };
|
|
295
|
+
};
|
|
296
|
+
const tags = parsed.format?.tags ?? {};
|
|
297
|
+
const lower: Record<string, string> = {};
|
|
298
|
+
for (const [k, v] of Object.entries(tags)) lower[k.toLowerCase()] = v;
|
|
299
|
+
return {
|
|
300
|
+
path: fallback.path,
|
|
301
|
+
title: lower.title || fallback.title,
|
|
302
|
+
artist: lower.artist ?? "",
|
|
303
|
+
album: lower.album ?? "",
|
|
304
|
+
duration: Number(parsed.format?.duration ?? 0) || 0,
|
|
305
|
+
};
|
|
306
|
+
} catch {
|
|
307
|
+
return fallback;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
235
311
|
/** What is actually inside a container, as opposed to what the name suggests. */
|
|
236
312
|
export interface Codecs {
|
|
237
313
|
/** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
|
|
@@ -293,7 +369,11 @@ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
|
293
369
|
* would cost a core per viewer and look worse. So the streams decide, one part
|
|
294
370
|
* at a time -- a film can have its video copied and only its DTS re-encoded.
|
|
295
371
|
*/
|
|
296
|
-
export function videoArgs(codecs: Codecs): string[] {
|
|
372
|
+
export function videoArgs(codecs: Codecs, capKbps = 0): string[] {
|
|
373
|
+
// A ceiling means re-encoding whatever is there, because you cannot cap the
|
|
374
|
+
// bitrate of a stream you are copying: copying is what "unchanged" means.
|
|
375
|
+
if (capKbps > 0) return cappedArgs(capKbps);
|
|
376
|
+
|
|
297
377
|
// What a browser can play inside MP4 without help.
|
|
298
378
|
const keepVideo = codecs.video === "h264";
|
|
299
379
|
const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
|
|
@@ -309,3 +389,42 @@ export function videoArgs(codecs: Codecs): string[] {
|
|
|
309
389
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
|
310
390
|
];
|
|
311
391
|
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The width that suits a bitrate.
|
|
395
|
+
*
|
|
396
|
+
* 1080p squeezed into a megabit is worse than 360p at the same megabit: the
|
|
397
|
+
* encoder spends everything it has on detail it cannot afford and the result
|
|
398
|
+
* smears on every motion. Dropping the resolution with the bitrate is what
|
|
399
|
+
* makes a small stream watchable rather than merely small.
|
|
400
|
+
*/
|
|
401
|
+
export function widthFor(kbps: number): number {
|
|
402
|
+
if (kbps <= 800) return 640;
|
|
403
|
+
if (kbps <= 1800) return 854;
|
|
404
|
+
if (kbps <= 4000) return 1280;
|
|
405
|
+
return 1920;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Arguments for a stream that has to fit through a link of a known size. */
|
|
409
|
+
function cappedArgs(kbps: number): string[] {
|
|
410
|
+
const audioKbps = kbps <= 800 ? 96 : 128;
|
|
411
|
+
const videoKbps = Math.max(200, kbps - audioKbps);
|
|
412
|
+
return [
|
|
413
|
+
"-c:v", "libx264",
|
|
414
|
+
"-preset", "veryfast",
|
|
415
|
+
"-pix_fmt", "yuv420p",
|
|
416
|
+
// -2 keeps the aspect ratio and an even height, which H.264 requires.
|
|
417
|
+
// The min() never enlarges: a 480p source asked for 720p stays 480p.
|
|
418
|
+
"-vf", `scale='min(${widthFor(kbps)},iw)':-2`,
|
|
419
|
+
"-b:v", `${videoKbps}k`,
|
|
420
|
+
// A ceiling rather than an average, because an average that spikes is a
|
|
421
|
+
// stall on a link this size. The buffer is one second of it.
|
|
422
|
+
"-maxrate", `${videoKbps}k`,
|
|
423
|
+
"-bufsize", `${videoKbps}k`,
|
|
424
|
+
"-c:a", "aac",
|
|
425
|
+
"-b:a", `${audioKbps}k`,
|
|
426
|
+
"-ac", "2",
|
|
427
|
+
"-f", "mp4",
|
|
428
|
+
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
|
429
|
+
];
|
|
430
|
+
}
|