nixamp 0.7.24 → 0.7.26
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 +10 -0
- package/dist/admin.js +23 -4
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +14 -1
- package/dist/server.js +67 -8
- package/package.json +1 -1
- package/src/admin.ts +34 -4
- package/src/daemon.ts +22 -1
- package/src/server.ts +70 -8
- package/web/dist/assets/{hls-3VKVEQE3-CYp2nKVZ.js → hls-3VKVEQE3-D-TeBz8d.js} +1 -1
- package/web/dist/assets/{index-CuPGZuSo.js → index-Z0E29INh.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-SQvnAUEE.js → mpegts-LO6RVLD6-Crs76Vy-.js} +1 -1
- package/web/dist/assets/{mpegts-BhdFS9RT.js → mpegts-melX63gT.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
package/dist/admin.d.ts
CHANGED
|
@@ -39,6 +39,14 @@ export interface AdminOptions {
|
|
|
39
39
|
label: string;
|
|
40
40
|
url: string;
|
|
41
41
|
}[];
|
|
42
|
+
/**
|
|
43
|
+
* The key that only watches, when this server has one.
|
|
44
|
+
*
|
|
45
|
+
* The links built from it are the ones to hand other people. Absent when
|
|
46
|
+
* pointed at a server by hand, since a listening key is not something you
|
|
47
|
+
* can work out from a control one.
|
|
48
|
+
*/
|
|
49
|
+
listenKey?: string | null;
|
|
42
50
|
/** What it is serving, so the admin view says so without being asked. */
|
|
43
51
|
source: string;
|
|
44
52
|
}
|
|
@@ -76,6 +84,8 @@ export interface View {
|
|
|
76
84
|
url: string;
|
|
77
85
|
}[];
|
|
78
86
|
key: string | null;
|
|
87
|
+
/** The key that only watches, for the links you hand to other people. */
|
|
88
|
+
listenKey?: string | null;
|
|
79
89
|
source: string;
|
|
80
90
|
}
|
|
81
91
|
export declare function draw(ui: Container, theme: Theme, view: View): void;
|
package/dist/admin.js
CHANGED
|
@@ -39,6 +39,7 @@ export function resolveTarget(argv) {
|
|
|
39
39
|
return {
|
|
40
40
|
url: url_,
|
|
41
41
|
key: key ?? state.key,
|
|
42
|
+
...(state.listenKey ? { listenKey: state.listenKey } : {}),
|
|
42
43
|
// A state file written before 0.5.3 has no list; loopback stands in.
|
|
43
44
|
links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
|
|
44
45
|
source: state.source,
|
|
@@ -176,7 +177,7 @@ export async function admin(argv) {
|
|
|
176
177
|
app.on("exit", () => clearInterval(timer));
|
|
177
178
|
app.render(({ ui, theme }) => draw(ui, theme, {
|
|
178
179
|
url: target.url, report, snapshot, error, typing, restreaming, replacing,
|
|
179
|
-
links: target.links, key: target.key, source: target.source,
|
|
180
|
+
links: target.links, key: target.key, listenKey: target.listenKey, source: target.source,
|
|
180
181
|
}));
|
|
181
182
|
await app.start();
|
|
182
183
|
clearInterval(timer);
|
|
@@ -229,13 +230,31 @@ export function draw(ui, theme, view) {
|
|
|
229
230
|
// reach it. The key is on them: without it every address is a 401.
|
|
230
231
|
if (view.links.length > 0) {
|
|
231
232
|
const width = Math.max(...view.links.map((link) => link.label.length));
|
|
232
|
-
|
|
233
|
+
// Both kinds, labelled, because they are not interchangeable and the
|
|
234
|
+
// operator had only ever been shown the one that drives. Handing that to
|
|
235
|
+
// somebody who wanted to watch hands them the controls with it, and
|
|
236
|
+
// there was nothing else to hand them.
|
|
237
|
+
// Counted rather than guessed: a heading of its own, the links, the second
|
|
238
|
+
// heading and its links when there is a viewing key, the source, and the
|
|
239
|
+
// two borders. A panel one line short silently drops the last row.
|
|
240
|
+
const rows = 1 + view.links.length
|
|
241
|
+
+ (view.listenKey ? 1 + view.links.length : 0)
|
|
242
|
+
+ (view.source ? 1 : 0);
|
|
243
|
+
ui.panel({ title: "Your links, and the ones to share", size: rows + 2 }, (p) => {
|
|
244
|
+
p.label("Yours -- these administer this server:");
|
|
233
245
|
for (const link of view.links) {
|
|
234
|
-
|
|
235
|
-
p.text(`${link.label.padEnd(width)} ${full}`, {
|
|
246
|
+
p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.key)}`, {
|
|
236
247
|
fg: link.label === "on the internet" ? theme.accent : theme.foreground,
|
|
237
248
|
});
|
|
238
249
|
}
|
|
250
|
+
if (view.listenKey) {
|
|
251
|
+
p.label("To share -- these watch and listen, and change nothing:");
|
|
252
|
+
for (const link of view.links) {
|
|
253
|
+
p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.listenKey, false)}`, {
|
|
254
|
+
fg: link.label === "on the internet" ? theme.accent : theme.foreground,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
239
258
|
if (view.source)
|
|
240
259
|
p.label(view.source);
|
|
241
260
|
});
|
package/dist/daemon.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export interface DaemonState {
|
|
|
4
4
|
port: number;
|
|
5
5
|
/** The share key, so `nixamp admin` can talk to it without being told. */
|
|
6
6
|
key: string | null;
|
|
7
|
+
/**
|
|
8
|
+
* The key that only views, for handing to somebody who should watch.
|
|
9
|
+
*
|
|
10
|
+
* Absent on a state file written by an older nixamp, and absent on a server
|
|
11
|
+
* running with no key at all.
|
|
12
|
+
*/
|
|
13
|
+
listenKey?: string | null;
|
|
7
14
|
source: string;
|
|
8
15
|
startedAt: number;
|
|
9
16
|
/** Where its output went, for when it died and you want to know why. */
|
package/dist/daemon.js
CHANGED
|
@@ -121,8 +121,11 @@ function spell(ms) {
|
|
|
121
121
|
* typed the command.
|
|
122
122
|
*/
|
|
123
123
|
export function daemonLines(state, uptimeMs) {
|
|
124
|
-
//
|
|
124
|
+
// Two links per address: the one that drives this server and the one that
|
|
125
|
+
// only watches it. Printing just the first left the operator with nothing to
|
|
126
|
+
// hand anybody -- and handing over the wrong one hands over the controls.
|
|
125
127
|
const link = (url) => shareLink(url, state.key ?? null);
|
|
128
|
+
const viewLink = (url) => shareLink(url, state.listenKey ?? null, false);
|
|
126
129
|
// A state file written by an older nixamp has no list, so host and port
|
|
127
130
|
// still stand in rather than printing nothing at all.
|
|
128
131
|
const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
|
|
@@ -131,6 +134,15 @@ export function daemonLines(state, uptimeMs) {
|
|
|
131
134
|
for (const { label, url } of addresses)
|
|
132
135
|
lines.push(` ${label.padEnd(width)} ${link(url)}`);
|
|
133
136
|
lines.push(` ${"source".padEnd(width)} ${state.source}`);
|
|
137
|
+
// The viewing links, said separately and said as what they are. Somebody who
|
|
138
|
+
// should be able to watch your library should not be handed the link that
|
|
139
|
+
// re-streams it, and the operator had nothing else to give them.
|
|
140
|
+
if (state.listenKey) {
|
|
141
|
+
lines.push("", " To share -- these watch and listen, and change nothing:");
|
|
142
|
+
for (const { label, url } of addresses) {
|
|
143
|
+
lines.push(` ${label.padEnd(width)} ${viewLink(url)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
134
146
|
if (uptimeMs !== undefined)
|
|
135
147
|
lines.push(` ${"up".padEnd(width)} ${spell(uptimeMs)}`);
|
|
136
148
|
if (state.guessedPublic) {
|
|
@@ -227,6 +239,7 @@ async function waitForAnnounce(log, timeoutMs) {
|
|
|
227
239
|
host: String(parsed["host"]),
|
|
228
240
|
port: Number(parsed["port"]),
|
|
229
241
|
key: parsed["key"] ?? null,
|
|
242
|
+
...(typeof parsed["listenKey"] === "string" ? { listenKey: parsed["listenKey"] } : {}),
|
|
230
243
|
source: String(parsed["source"]),
|
|
231
244
|
...(Array.isArray(urls) ? { urls: urls } : {}),
|
|
232
245
|
...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
|
package/dist/server.js
CHANGED
|
@@ -2200,7 +2200,7 @@ export function createHandler(engine, options) {
|
|
|
2200
2200
|
return;
|
|
2201
2201
|
}
|
|
2202
2202
|
watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
|
|
2203
|
-
liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
|
|
2203
|
+
await liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"], options.ffprobe ?? ["ffprobe"]);
|
|
2204
2204
|
return;
|
|
2205
2205
|
}
|
|
2206
2206
|
if (path.startsWith("/api/stream/")) {
|
|
@@ -2276,8 +2276,33 @@ const LIVE_IDLE_MS = 2000;
|
|
|
2276
2276
|
* seconds and then sit waiting for the player to catch up, so the thing that
|
|
2277
2277
|
* decides what plays next would be minutes behind what the listener hears.
|
|
2278
2278
|
*/
|
|
2279
|
-
|
|
2279
|
+
/**
|
|
2280
|
+
* The server's own output, as one address that keeps playing.
|
|
2281
|
+
*
|
|
2282
|
+
* This is the watch party: everybody pointed at it hears and sees whatever the
|
|
2283
|
+
* server is playing, and somebody joining halfway through joins halfway
|
|
2284
|
+
* through rather than starting the film again on their own.
|
|
2285
|
+
*
|
|
2286
|
+
* A film comes with its picture. It used to be `-vn` and MP3 whatever it was,
|
|
2287
|
+
* so inviting people to watch a film got them its soundtrack -- which is not
|
|
2288
|
+
* an invitation anybody wants. The container is decided when the connection
|
|
2289
|
+
* opens, because a response has one content type and MP4 and MP3 cannot be
|
|
2290
|
+
* spliced; going from a film to a song ends the stream, and a client that
|
|
2291
|
+
* wants to keep listening asks again and gets the right one.
|
|
2292
|
+
*/
|
|
2293
|
+
async function liveAudio(request, response, engine, ffmpeg, ffprobe) {
|
|
2280
2294
|
const [command, ...prefix] = ffmpeg;
|
|
2295
|
+
/** Whether this track is something to watch rather than only to hear. */
|
|
2296
|
+
const looksLikeVideo = async (source) => {
|
|
2297
|
+
if (hasPicture(source))
|
|
2298
|
+
return true;
|
|
2299
|
+
if (!nameSaysNothing(source))
|
|
2300
|
+
return false;
|
|
2301
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
|
|
2302
|
+
return codecs.video !== "";
|
|
2303
|
+
};
|
|
2304
|
+
const first = engine.trackPath(engine.snapshot().index);
|
|
2305
|
+
const asVideo = first === undefined ? false : await looksLikeVideo(first);
|
|
2281
2306
|
let child = null;
|
|
2282
2307
|
let waiting = null;
|
|
2283
2308
|
let closed = false;
|
|
@@ -2291,7 +2316,7 @@ function liveAudio(request, response, engine, ffmpeg) {
|
|
|
2291
2316
|
started = true;
|
|
2292
2317
|
response.writeHead(200, {
|
|
2293
2318
|
...CORS,
|
|
2294
|
-
"content-type": "audio/mpeg",
|
|
2319
|
+
"content-type": asVideo ? "video/mp4" : "audio/mpeg",
|
|
2295
2320
|
"cache-control": "no-store",
|
|
2296
2321
|
"transfer-encoding": "chunked",
|
|
2297
2322
|
});
|
|
@@ -2331,16 +2356,28 @@ function liveAudio(request, response, engine, ffmpeg) {
|
|
|
2331
2356
|
return;
|
|
2332
2357
|
}
|
|
2333
2358
|
playing = snapshot.index;
|
|
2359
|
+
// Joined where the server is, not where the track begins. Somebody
|
|
2360
|
+
// arriving forty minutes into a film should arrive forty minutes in;
|
|
2361
|
+
// starting it again for them is not a watch party, it is two people
|
|
2362
|
+
// watching the same film separately.
|
|
2363
|
+
//
|
|
2364
|
+
// Before -i, so ffmpeg seeks rather than decoding its way there.
|
|
2365
|
+
const from = Math.max(0, Math.floor(snapshot.position));
|
|
2334
2366
|
const spawned = spawn(command, [
|
|
2335
2367
|
...prefix,
|
|
2336
2368
|
"-hide_banner",
|
|
2337
2369
|
"-loglevel", "error",
|
|
2338
2370
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
2371
|
+
// A live source has no beginning to seek from.
|
|
2372
|
+
...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
|
|
2339
2373
|
"-re",
|
|
2340
2374
|
"-i", source,
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2375
|
+
...(asVideo
|
|
2376
|
+
// Copied where it can be, because a room full of viewers is a room
|
|
2377
|
+
// full of encoders otherwise.
|
|
2378
|
+
? ["-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
|
|
2379
|
+
"-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
|
|
2380
|
+
: ["-vn", "-f", "mp3", "-b:a", "192k"]),
|
|
2344
2381
|
"-",
|
|
2345
2382
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
2346
2383
|
child = spawned;
|
|
@@ -2366,8 +2403,26 @@ function liveAudio(request, response, engine, ffmpeg) {
|
|
|
2366
2403
|
// A track change that lands while we are between songs is the signal to go
|
|
2367
2404
|
// now rather than wait out the poll.
|
|
2368
2405
|
const unsubscribe = engine.subscribe(() => {
|
|
2369
|
-
if (
|
|
2370
|
-
|
|
2406
|
+
if (closed || child !== null)
|
|
2407
|
+
return;
|
|
2408
|
+
if (engine.snapshot().index === playing)
|
|
2409
|
+
return;
|
|
2410
|
+
// The kind changed under us -- a film after a song, or the other way --
|
|
2411
|
+
// and one response cannot carry both. Ending it is how the client is told
|
|
2412
|
+
// to ask again, which it does.
|
|
2413
|
+
const source = engine.trackPath(engine.snapshot().index);
|
|
2414
|
+
if (source !== undefined) {
|
|
2415
|
+
void looksLikeVideo(source).then((wants) => {
|
|
2416
|
+
if (closed)
|
|
2417
|
+
return;
|
|
2418
|
+
if (wants !== asVideo)
|
|
2419
|
+
stop();
|
|
2420
|
+
else
|
|
2421
|
+
next();
|
|
2422
|
+
});
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
next();
|
|
2371
2426
|
});
|
|
2372
2427
|
response.on("close", stop);
|
|
2373
2428
|
response.on("error", stop);
|
|
@@ -2907,6 +2962,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2907
2962
|
host: options.host,
|
|
2908
2963
|
port,
|
|
2909
2964
|
key,
|
|
2965
|
+
// The other half of the pair. Without it `nixamp admin` can only draw
|
|
2966
|
+
// the links that administer, so the operator has nothing to hand
|
|
2967
|
+
// somebody who should be able to watch and not to drive.
|
|
2968
|
+
listenKey,
|
|
2910
2969
|
source: root,
|
|
2911
2970
|
urls: addresses,
|
|
2912
2971
|
firewall,
|
package/package.json
CHANGED
package/src/admin.ts
CHANGED
|
@@ -38,6 +38,14 @@ export interface AdminOptions {
|
|
|
38
38
|
* anybody. These are the ones worth reading off the screen.
|
|
39
39
|
*/
|
|
40
40
|
links: { label: string; url: string }[];
|
|
41
|
+
/**
|
|
42
|
+
* The key that only watches, when this server has one.
|
|
43
|
+
*
|
|
44
|
+
* The links built from it are the ones to hand other people. Absent when
|
|
45
|
+
* pointed at a server by hand, since a listening key is not something you
|
|
46
|
+
* can work out from a control one.
|
|
47
|
+
*/
|
|
48
|
+
listenKey?: string | null;
|
|
41
49
|
/** What it is serving, so the admin view says so without being asked. */
|
|
42
50
|
source: string;
|
|
43
51
|
}
|
|
@@ -76,6 +84,7 @@ export function resolveTarget(argv: string[]): AdminOptions {
|
|
|
76
84
|
return {
|
|
77
85
|
url: url_,
|
|
78
86
|
key: key ?? state.key,
|
|
87
|
+
...(state.listenKey ? { listenKey: state.listenKey } : {}),
|
|
79
88
|
// A state file written before 0.5.3 has no list; loopback stands in.
|
|
80
89
|
links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
|
|
81
90
|
source: state.source,
|
|
@@ -198,7 +207,7 @@ export async function admin(argv: string[]): Promise<void> {
|
|
|
198
207
|
app.on("exit", () => clearInterval(timer));
|
|
199
208
|
app.render(({ ui, theme }) => draw(ui, theme, {
|
|
200
209
|
url: target.url, report, snapshot, error, typing, restreaming, replacing,
|
|
201
|
-
links: target.links, key: target.key, source: target.source,
|
|
210
|
+
links: target.links, key: target.key, listenKey: target.listenKey, source: target.source,
|
|
202
211
|
}));
|
|
203
212
|
|
|
204
213
|
await app.start();
|
|
@@ -242,6 +251,8 @@ export interface View {
|
|
|
242
251
|
/** Labelled addresses, and the key that makes them work. */
|
|
243
252
|
links: { label: string; url: string }[];
|
|
244
253
|
key: string | null;
|
|
254
|
+
/** The key that only watches, for the links you hand to other people. */
|
|
255
|
+
listenKey?: string | null;
|
|
245
256
|
source: string;
|
|
246
257
|
}
|
|
247
258
|
|
|
@@ -276,13 +287,32 @@ export function draw(ui: Container, theme: Theme, view: View): void {
|
|
|
276
287
|
// reach it. The key is on them: without it every address is a 401.
|
|
277
288
|
if (view.links.length > 0) {
|
|
278
289
|
const width = Math.max(...view.links.map((link) => link.label.length));
|
|
279
|
-
|
|
290
|
+
// Both kinds, labelled, because they are not interchangeable and the
|
|
291
|
+
// operator had only ever been shown the one that drives. Handing that to
|
|
292
|
+
// somebody who wanted to watch hands them the controls with it, and
|
|
293
|
+
// there was nothing else to hand them.
|
|
294
|
+
// Counted rather than guessed: a heading of its own, the links, the second
|
|
295
|
+
// heading and its links when there is a viewing key, the source, and the
|
|
296
|
+
// two borders. A panel one line short silently drops the last row.
|
|
297
|
+
const rows =
|
|
298
|
+
1 + view.links.length
|
|
299
|
+
+ (view.listenKey ? 1 + view.links.length : 0)
|
|
300
|
+
+ (view.source ? 1 : 0);
|
|
301
|
+
ui.panel({ title: "Your links, and the ones to share", size: rows + 2 }, (p) => {
|
|
302
|
+
p.label("Yours -- these administer this server:");
|
|
280
303
|
for (const link of view.links) {
|
|
281
|
-
|
|
282
|
-
p.text(`${link.label.padEnd(width)} ${full}`, {
|
|
304
|
+
p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.key)}`, {
|
|
283
305
|
fg: link.label === "on the internet" ? theme.accent : theme.foreground,
|
|
284
306
|
});
|
|
285
307
|
}
|
|
308
|
+
if (view.listenKey) {
|
|
309
|
+
p.label("To share -- these watch and listen, and change nothing:");
|
|
310
|
+
for (const link of view.links) {
|
|
311
|
+
p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.listenKey, false)}`, {
|
|
312
|
+
fg: link.label === "on the internet" ? theme.accent : theme.foreground,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
286
316
|
if (view.source) p.label(view.source);
|
|
287
317
|
});
|
|
288
318
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface DaemonState {
|
|
|
18
18
|
port: number;
|
|
19
19
|
/** The share key, so `nixamp admin` can talk to it without being told. */
|
|
20
20
|
key: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* The key that only views, for handing to somebody who should watch.
|
|
23
|
+
*
|
|
24
|
+
* Absent on a state file written by an older nixamp, and absent on a server
|
|
25
|
+
* running with no key at all.
|
|
26
|
+
*/
|
|
27
|
+
listenKey?: string | null;
|
|
21
28
|
source: string;
|
|
22
29
|
startedAt: number;
|
|
23
30
|
/** Where its output went, for when it died and you want to know why. */
|
|
@@ -164,8 +171,11 @@ function spell(ms: number): string {
|
|
|
164
171
|
* typed the command.
|
|
165
172
|
*/
|
|
166
173
|
export function daemonLines(state: DaemonState, uptimeMs?: number): string[] {
|
|
167
|
-
//
|
|
174
|
+
// Two links per address: the one that drives this server and the one that
|
|
175
|
+
// only watches it. Printing just the first left the operator with nothing to
|
|
176
|
+
// hand anybody -- and handing over the wrong one hands over the controls.
|
|
168
177
|
const link = (url: string): string => shareLink(url, state.key ?? null);
|
|
178
|
+
const viewLink = (url: string): string => shareLink(url, state.listenKey ?? null, false);
|
|
169
179
|
// A state file written by an older nixamp has no list, so host and port
|
|
170
180
|
// still stand in rather than printing nothing at all.
|
|
171
181
|
const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
|
|
@@ -174,6 +184,16 @@ export function daemonLines(state: DaemonState, uptimeMs?: number): string[] {
|
|
|
174
184
|
const lines = [`nixamp daemon running (pid ${state.pid})`];
|
|
175
185
|
for (const { label, url } of addresses) lines.push(` ${label.padEnd(width)} ${link(url)}`);
|
|
176
186
|
lines.push(` ${"source".padEnd(width)} ${state.source}`);
|
|
187
|
+
|
|
188
|
+
// The viewing links, said separately and said as what they are. Somebody who
|
|
189
|
+
// should be able to watch your library should not be handed the link that
|
|
190
|
+
// re-streams it, and the operator had nothing else to give them.
|
|
191
|
+
if (state.listenKey) {
|
|
192
|
+
lines.push("", " To share -- these watch and listen, and change nothing:");
|
|
193
|
+
for (const { label, url } of addresses) {
|
|
194
|
+
lines.push(` ${label.padEnd(width)} ${viewLink(url)}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
177
197
|
if (uptimeMs !== undefined) lines.push(` ${"up".padEnd(width)} ${spell(uptimeMs)}`);
|
|
178
198
|
|
|
179
199
|
if (state.guessedPublic) {
|
|
@@ -300,6 +320,7 @@ async function waitForAnnounce(
|
|
|
300
320
|
host: String(parsed["host"]),
|
|
301
321
|
port: Number(parsed["port"]),
|
|
302
322
|
key: (parsed["key"] as string | null) ?? null,
|
|
323
|
+
...(typeof parsed["listenKey"] === "string" ? { listenKey: parsed["listenKey"] } : {}),
|
|
303
324
|
source: String(parsed["source"]),
|
|
304
325
|
...(Array.isArray(urls) ? { urls: urls as { label: string; url: string }[] } : {}),
|
|
305
326
|
...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
|
package/src/server.ts
CHANGED
|
@@ -2611,7 +2611,13 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2611
2611
|
return;
|
|
2612
2612
|
}
|
|
2613
2613
|
watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
|
|
2614
|
-
liveAudio(
|
|
2614
|
+
await liveAudio(
|
|
2615
|
+
request,
|
|
2616
|
+
response,
|
|
2617
|
+
engine,
|
|
2618
|
+
options.ffmpeg ?? ["ffmpeg"],
|
|
2619
|
+
options.ffprobe ?? ["ffprobe"],
|
|
2620
|
+
);
|
|
2615
2621
|
return;
|
|
2616
2622
|
}
|
|
2617
2623
|
|
|
@@ -2690,13 +2696,39 @@ const LIVE_IDLE_MS = 2000;
|
|
|
2690
2696
|
* seconds and then sit waiting for the player to catch up, so the thing that
|
|
2691
2697
|
* decides what plays next would be minutes behind what the listener hears.
|
|
2692
2698
|
*/
|
|
2693
|
-
|
|
2699
|
+
/**
|
|
2700
|
+
* The server's own output, as one address that keeps playing.
|
|
2701
|
+
*
|
|
2702
|
+
* This is the watch party: everybody pointed at it hears and sees whatever the
|
|
2703
|
+
* server is playing, and somebody joining halfway through joins halfway
|
|
2704
|
+
* through rather than starting the film again on their own.
|
|
2705
|
+
*
|
|
2706
|
+
* A film comes with its picture. It used to be `-vn` and MP3 whatever it was,
|
|
2707
|
+
* so inviting people to watch a film got them its soundtrack -- which is not
|
|
2708
|
+
* an invitation anybody wants. The container is decided when the connection
|
|
2709
|
+
* opens, because a response has one content type and MP4 and MP3 cannot be
|
|
2710
|
+
* spliced; going from a film to a song ends the stream, and a client that
|
|
2711
|
+
* wants to keep listening asks again and gets the right one.
|
|
2712
|
+
*/
|
|
2713
|
+
async function liveAudio(
|
|
2694
2714
|
request: IncomingMessage,
|
|
2695
2715
|
response: ServerResponse,
|
|
2696
2716
|
engine: Engine,
|
|
2697
2717
|
ffmpeg: string[],
|
|
2698
|
-
|
|
2718
|
+
ffprobe: string[],
|
|
2719
|
+
): Promise<void> {
|
|
2699
2720
|
const [command, ...prefix] = ffmpeg as [string, ...string[]];
|
|
2721
|
+
|
|
2722
|
+
/** Whether this track is something to watch rather than only to hear. */
|
|
2723
|
+
const looksLikeVideo = async (source: string): Promise<boolean> => {
|
|
2724
|
+
if (hasPicture(source)) return true;
|
|
2725
|
+
if (!nameSaysNothing(source)) return false;
|
|
2726
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
|
|
2727
|
+
return codecs.video !== "";
|
|
2728
|
+
};
|
|
2729
|
+
|
|
2730
|
+
const first = engine.trackPath(engine.snapshot().index);
|
|
2731
|
+
const asVideo = first === undefined ? false : await looksLikeVideo(first);
|
|
2700
2732
|
let child: ReturnType<typeof spawn> | null = null;
|
|
2701
2733
|
let waiting: ReturnType<typeof setTimeout> | null = null;
|
|
2702
2734
|
let closed = false;
|
|
@@ -2710,7 +2742,7 @@ function liveAudio(
|
|
|
2710
2742
|
started = true;
|
|
2711
2743
|
response.writeHead(200, {
|
|
2712
2744
|
...CORS,
|
|
2713
|
-
"content-type": "audio/mpeg",
|
|
2745
|
+
"content-type": asVideo ? "video/mp4" : "audio/mpeg",
|
|
2714
2746
|
"cache-control": "no-store",
|
|
2715
2747
|
"transfer-encoding": "chunked",
|
|
2716
2748
|
});
|
|
@@ -2749,6 +2781,13 @@ function liveAudio(
|
|
|
2749
2781
|
}
|
|
2750
2782
|
|
|
2751
2783
|
playing = snapshot.index;
|
|
2784
|
+
// Joined where the server is, not where the track begins. Somebody
|
|
2785
|
+
// arriving forty minutes into a film should arrive forty minutes in;
|
|
2786
|
+
// starting it again for them is not a watch party, it is two people
|
|
2787
|
+
// watching the same film separately.
|
|
2788
|
+
//
|
|
2789
|
+
// Before -i, so ffmpeg seeks rather than decoding its way there.
|
|
2790
|
+
const from = Math.max(0, Math.floor(snapshot.position));
|
|
2752
2791
|
const spawned = spawn(
|
|
2753
2792
|
command,
|
|
2754
2793
|
[
|
|
@@ -2756,11 +2795,16 @@ function liveAudio(
|
|
|
2756
2795
|
"-hide_banner",
|
|
2757
2796
|
"-loglevel", "error",
|
|
2758
2797
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
2798
|
+
// A live source has no beginning to seek from.
|
|
2799
|
+
...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
|
|
2759
2800
|
"-re",
|
|
2760
2801
|
"-i", source,
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2802
|
+
...(asVideo
|
|
2803
|
+
// Copied where it can be, because a room full of viewers is a room
|
|
2804
|
+
// full of encoders otherwise.
|
|
2805
|
+
? ["-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
|
|
2806
|
+
"-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
|
|
2807
|
+
: ["-vn", "-f", "mp3", "-b:a", "192k"]),
|
|
2764
2808
|
"-",
|
|
2765
2809
|
],
|
|
2766
2810
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
@@ -2789,7 +2833,21 @@ function liveAudio(
|
|
|
2789
2833
|
// A track change that lands while we are between songs is the signal to go
|
|
2790
2834
|
// now rather than wait out the poll.
|
|
2791
2835
|
const unsubscribe = engine.subscribe(() => {
|
|
2792
|
-
if (
|
|
2836
|
+
if (closed || child !== null) return;
|
|
2837
|
+
if (engine.snapshot().index === playing) return;
|
|
2838
|
+
// The kind changed under us -- a film after a song, or the other way --
|
|
2839
|
+
// and one response cannot carry both. Ending it is how the client is told
|
|
2840
|
+
// to ask again, which it does.
|
|
2841
|
+
const source = engine.trackPath(engine.snapshot().index);
|
|
2842
|
+
if (source !== undefined) {
|
|
2843
|
+
void looksLikeVideo(source).then((wants) => {
|
|
2844
|
+
if (closed) return;
|
|
2845
|
+
if (wants !== asVideo) stop();
|
|
2846
|
+
else next();
|
|
2847
|
+
});
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
next();
|
|
2793
2851
|
});
|
|
2794
2852
|
|
|
2795
2853
|
response.on("close", stop);
|
|
@@ -3389,6 +3447,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3389
3447
|
host: options.host,
|
|
3390
3448
|
port,
|
|
3391
3449
|
key,
|
|
3450
|
+
// The other half of the pair. Without it `nixamp admin` can only draw
|
|
3451
|
+
// the links that administer, so the operator has nothing to hand
|
|
3452
|
+
// somebody who should be able to watch and not to drive.
|
|
3453
|
+
listenKey,
|
|
3392
3454
|
source: root,
|
|
3393
3455
|
urls: addresses,
|
|
3394
3456
|
firewall,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-Z0E29INh.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|