nixamp 0.17.0 → 0.18.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/channels.d.ts +54 -1
- package/dist/channels.js +160 -23
- package/dist/compression/benchmark.d.ts +97 -0
- package/dist/compression/benchmark.js +241 -0
- package/dist/compression/cli.js +59 -1
- package/dist/compression/receiver.d.ts +25 -5
- package/dist/compression/receiver.js +54 -5
- package/dist/compression/routes.js +7 -2
- package/dist/compression/service.d.ts +18 -6
- package/dist/compression/service.js +110 -19
- package/dist/compression/source.d.ts +17 -0
- package/dist/compression/source.js +131 -0
- package/dist/links.js +14 -3
- package/dist/live-api.js +1 -0
- package/dist/live-events.js +11 -3
- package/dist/owner.js +10 -0
- package/dist/server.js +118 -10
- package/dist/share.js +6 -0
- package/package.json +1 -1
- package/src/channels.ts +180 -22
- package/src/compression/benchmark.ts +297 -0
- package/src/compression/cli.ts +56 -1
- package/src/compression/receiver.ts +68 -7
- package/src/compression/routes.ts +7 -2
- package/src/compression/service.ts +121 -24
- package/src/compression/source.ts +130 -0
- package/src/links.ts +12 -3
- package/src/live-api.ts +1 -0
- package/src/live-events.ts +14 -3
- package/src/owner.ts +8 -0
- package/src/server.ts +113 -11
- package/src/share.ts +5 -0
- package/web/dist/assets/{hls-3VKVEQE3-eV54kXE3.js → hls-3VKVEQE3-B2kl0-z1.js} +1 -1
- package/web/dist/assets/{index-CurZFzlH.css → index-D1QxpRmE.css} +1 -1
- package/web/dist/assets/index-DF6O2leR.js +1 -0
- package/web/dist/assets/{mpegts-Buc3Odv6.js → mpegts-4pBNK_Yr.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DcDKPB4P.js → mpegts-LO6RVLD6-B4RLM-_e.js} +1 -1
- package/web/dist/index.html +15 -10
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-DDzutJ75.js +0 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* translates flags into the routes. There is no second copy of the rules.
|
|
9
9
|
*/
|
|
10
10
|
import { statSync } from "node:fs";
|
|
11
|
-
import { codecsOf } from "../audio.js";
|
|
11
|
+
import { codecsOf, videoArgs } from "../audio.js";
|
|
12
12
|
import { GIVE_UP, REDIAL } from "../channels.js";
|
|
13
13
|
import { analyzeSample, SAMPLE_MAX_BYTES, SAMPLE_MAX_SECONDS } from "./analyze.js";
|
|
14
14
|
import { Pool } from "./codec.js";
|
|
@@ -16,8 +16,9 @@ import { RelayError } from "./envelope.js";
|
|
|
16
16
|
import { AnalysisJobs } from "./jobs.js";
|
|
17
17
|
import { ChannelMetrics } from "./metrics.js";
|
|
18
18
|
import { variantOf } from "./policy.js";
|
|
19
|
-
import { receiveRelay, RelayRefused } from "./receiver.js";
|
|
19
|
+
import { probeRelay, receiveRelay, RelayRefused } from "./receiver.js";
|
|
20
20
|
import { RelayEncoder } from "./relay.js";
|
|
21
|
+
import { relayThrough, sourceThrough, unreadable } from "./source.js";
|
|
21
22
|
import { PolicyStore } from "./store.js";
|
|
22
23
|
import { StaticCache } from "./static.js";
|
|
23
24
|
/** The pseudo-channel whose policy governs static file representations. */
|
|
@@ -59,7 +60,17 @@ export async function analyzeFile(path, options = {}) {
|
|
|
59
60
|
...(ffprobe ? { ffprobeVersion: ffprobe.join(" ") } : {}),
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* An incoming relay: this server as the receiver.
|
|
65
|
+
*
|
|
66
|
+
* Two shapes, decided by what the sender says it carries. A channel-boundary
|
|
67
|
+
* relay is the sender's finished output, so it is fed straight into a
|
|
68
|
+
* channel here and dialled again by this class when it drops. A
|
|
69
|
+
* source-boundary relay is the original transport stream, so it is handed
|
|
70
|
+
* to a channel's own ffmpeg as a source read through us -- which plays it
|
|
71
|
+
* exactly as the sender would have, lets it be relayed on again, and puts
|
|
72
|
+
* the redialling in the channel where it already lives.
|
|
73
|
+
*/
|
|
63
74
|
class Incoming {
|
|
64
75
|
id;
|
|
65
76
|
from;
|
|
@@ -182,7 +193,25 @@ export class CompressionService {
|
|
|
182
193
|
this.statics = options.cacheDir
|
|
183
194
|
? new StaticCache(options.cacheDir, { pool: this.pool, ...(options.maxCacheBytes !== undefined ? { maxBytes: options.maxCacheBytes } : {}) })
|
|
184
195
|
: null;
|
|
196
|
+
// Whether a pulled source is read here rather than by ffmpeg, decided at
|
|
197
|
+
// every dial: only when the channel's policy asks for the source boundary
|
|
198
|
+
// and the source is one we can read exactly (a plain transport stream).
|
|
199
|
+
// A channel we brought in from another nixamp's source-boundary relay is
|
|
200
|
+
// itself read through us, so it can be relayed on.
|
|
201
|
+
this.channels.setThrough((info, from, input, audio) => {
|
|
202
|
+
const relayed = this.relaySource.get(info.id);
|
|
203
|
+
if (relayed)
|
|
204
|
+
return relayThrough(relayed.from, relayed.key);
|
|
205
|
+
const policy = this.store.get(info.id).losslessCompression;
|
|
206
|
+
if (policy.mode === "off" || policy.boundary !== "source")
|
|
207
|
+
return null;
|
|
208
|
+
if (unreadable(info, from, input, audio) !== null)
|
|
209
|
+
return null;
|
|
210
|
+
return sourceThrough(info.source ?? "");
|
|
211
|
+
});
|
|
185
212
|
}
|
|
213
|
+
/** Incoming source-boundary relays, so their channel is read through us. */
|
|
214
|
+
relaySource = new Map();
|
|
186
215
|
get channels() {
|
|
187
216
|
return this.options.channels;
|
|
188
217
|
}
|
|
@@ -197,8 +226,15 @@ export class CompressionService {
|
|
|
197
226
|
reason = "compression is off for the whole server";
|
|
198
227
|
}
|
|
199
228
|
else if (lossless.mode !== "off" && lossless.boundary === "source") {
|
|
200
|
-
|
|
201
|
-
|
|
229
|
+
// The source boundary is available only for a source we can read here.
|
|
230
|
+
// A live channel that is not read through us cannot offer it; a channel
|
|
231
|
+
// that is not on yet is given the benefit of the doubt until it is.
|
|
232
|
+
const info = this.channels.info(id);
|
|
233
|
+
const why = info ? unreadable(info, 0, [], "") : null;
|
|
234
|
+
if (why !== null) {
|
|
235
|
+
lossless.mode = "off";
|
|
236
|
+
reason = `original source bytes are unavailable: ${why}`;
|
|
237
|
+
}
|
|
202
238
|
}
|
|
203
239
|
return {
|
|
204
240
|
losslessCompression: lossless,
|
|
@@ -290,7 +326,9 @@ export class CompressionService {
|
|
|
290
326
|
}
|
|
291
327
|
// A receiver that cannot undo the transform gets a variant without it.
|
|
292
328
|
const variantPolicy = { ...policy, tsAware: policy.tsAware && codecs.includes("ts-zstd") };
|
|
293
|
-
|
|
329
|
+
// The boundary is part of the variant: a source relay and a channel
|
|
330
|
+
// relay are two different streams, and two different compressors.
|
|
331
|
+
const variant = `${policy.boundary}:${variantOf(variantPolicy)}`;
|
|
294
332
|
let running = this.running.get(id);
|
|
295
333
|
if (running && running.variant !== variant) {
|
|
296
334
|
// One compressor per channel. A second variant would be a second
|
|
@@ -298,17 +336,31 @@ export class CompressionService {
|
|
|
298
336
|
return { ok: false, status: 409, code: "VARIANT_IN_USE", error: "this channel is already being relayed under a different codec set; try again when that relay ends" };
|
|
299
337
|
}
|
|
300
338
|
if (!running) {
|
|
301
|
-
const started = this.startEncoder(id, variantPolicy, variant);
|
|
302
|
-
if (!started)
|
|
303
|
-
return
|
|
339
|
+
const started = this.startEncoder(id, variantPolicy, variant, policy.boundary);
|
|
340
|
+
if (!started) {
|
|
341
|
+
return started === null
|
|
342
|
+
? { ok: false, status: 503, code: "CHANNEL_GONE", error: "the channel ended before the relay could start" }
|
|
343
|
+
: { ok: false, status: 409, code: "SOURCE_BOUNDARY_UNAVAILABLE", error: "the source is not being read through this server, so its original bytes are not available to relay" };
|
|
344
|
+
}
|
|
304
345
|
running = started;
|
|
305
346
|
}
|
|
306
|
-
//
|
|
307
|
-
|
|
347
|
+
// For the channel boundary, the opening bytes are the preface. The source
|
|
348
|
+
// boundary has none: a joining demuxer re-syncs on the next PAT and
|
|
349
|
+
// keyframe, exactly as it does when a live source is joined.
|
|
350
|
+
const preface = policy.boundary === "source" ? [] : this.channels.opening(id);
|
|
308
351
|
const session = running.encoder.join(listener, preface);
|
|
309
|
-
return {
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
session,
|
|
355
|
+
codecs,
|
|
356
|
+
generation: running.encoder.generation,
|
|
357
|
+
kind: this.channels.kindOf(id) ?? "",
|
|
358
|
+
boundary: policy.boundary,
|
|
359
|
+
media: this.channels.info(id)?.codecs ?? null,
|
|
360
|
+
};
|
|
310
361
|
}
|
|
311
|
-
|
|
362
|
+
/** Null: the channel ended. False: the source boundary was asked for but is not being read through us. */
|
|
363
|
+
startEncoder(id, policy, variant, boundary) {
|
|
312
364
|
this.generation = (this.generation + 1) % 0xffff_ffff;
|
|
313
365
|
const metrics = this.metrics.get(id) ?? new ChannelMetrics();
|
|
314
366
|
this.metrics.set(id, metrics);
|
|
@@ -318,7 +370,7 @@ export class CompressionService {
|
|
|
318
370
|
policy,
|
|
319
371
|
pool: this.pool,
|
|
320
372
|
metrics,
|
|
321
|
-
boundary
|
|
373
|
+
boundary,
|
|
322
374
|
onAbort: () => {
|
|
323
375
|
if (record && this.running.get(id) === record) {
|
|
324
376
|
this.running.delete(id);
|
|
@@ -335,24 +387,61 @@ export class CompressionService {
|
|
|
335
387
|
}
|
|
336
388
|
},
|
|
337
389
|
});
|
|
338
|
-
const
|
|
390
|
+
const face = {
|
|
339
391
|
write: (chunk) => encoder.write(chunk),
|
|
340
392
|
end: () => encoder.end(),
|
|
341
393
|
pending: () => metrics.queueBytes,
|
|
342
|
-
}
|
|
394
|
+
};
|
|
395
|
+
// The source boundary taps the source's own bytes; the channel boundary
|
|
396
|
+
// is one more ordinary listener on the channel's output.
|
|
397
|
+
const detach = boundary === "source" ? this.channels.tapSource(id, face) : this.channels.listen(id, face);
|
|
343
398
|
if (detach === null)
|
|
344
|
-
return null;
|
|
399
|
+
return boundary === "source" ? false : null;
|
|
345
400
|
encoder.attach();
|
|
346
401
|
record = { encoder, detach, variant };
|
|
347
402
|
this.running.set(id, record);
|
|
348
403
|
return record;
|
|
349
404
|
}
|
|
350
|
-
/**
|
|
351
|
-
|
|
405
|
+
/**
|
|
406
|
+
* Start listening to another nixamp's channel as one of ours. Probes the
|
|
407
|
+
* relay first to learn its boundary: a channel-boundary relay is decoded
|
|
408
|
+
* and its bytes are the channel's output directly; a source-boundary relay
|
|
409
|
+
* is the original transport stream, so it is read through ffmpeg here
|
|
410
|
+
* exactly as a pulled source would be, and can be relayed on again.
|
|
411
|
+
*/
|
|
412
|
+
async pull(id, from, key, name) {
|
|
352
413
|
if (!/^https?:\/\//i.test(from))
|
|
353
414
|
return { ok: false, status: 400, error: "the relay address must be http or https" };
|
|
354
415
|
if (this.channels.has(id) || this.incoming.has(id))
|
|
355
416
|
return { ok: false, status: 409, error: `channel "${id}" is already on` };
|
|
417
|
+
let accepted;
|
|
418
|
+
try {
|
|
419
|
+
accepted = await probeRelay(from, key);
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
const message = error instanceof RelayRefused ? `the relay refused: ${error.message}` : error.message;
|
|
423
|
+
return { ok: false, status: 502, error: message };
|
|
424
|
+
}
|
|
425
|
+
if (accepted.boundary === "source") {
|
|
426
|
+
// The upstream sends the original transport stream. Read it through
|
|
427
|
+
// ffmpeg here: the through-provider sees this registration and hands
|
|
428
|
+
// ffmpeg the decoded bytes. It can then be relayed on at either
|
|
429
|
+
// boundary, since its source now passes through this process.
|
|
430
|
+
const media = accepted.media;
|
|
431
|
+
const kind = accepted.kind === "audio" ? "audio" : "video";
|
|
432
|
+
const encode = kind === "video"
|
|
433
|
+
? videoArgs(media ?? { video: "h264", audio: "aac", container: "mpegts" })
|
|
434
|
+
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
435
|
+
this.relaySource.set(id, { from, key });
|
|
436
|
+
// A placeholder source string: the through-provider supplies the bytes,
|
|
437
|
+
// so this is only what a listener would never see and a restart reads.
|
|
438
|
+
const channel = this.channels.pull(id, name, from, encode, kind, true, undefined, [], "", { live: true, position: 0 }, media ?? undefined);
|
|
439
|
+
if (!channel) {
|
|
440
|
+
this.relaySource.delete(id);
|
|
441
|
+
return { ok: false, status: 409, error: `channel "${id}" is already on` };
|
|
442
|
+
}
|
|
443
|
+
return { ok: true };
|
|
444
|
+
}
|
|
356
445
|
const inbound = new Incoming(id, from, key, name, this.channels, this.options.onEvent ?? (() => undefined), (gone) => this.incoming.delete(gone));
|
|
357
446
|
this.incoming.set(id, inbound);
|
|
358
447
|
inbound.start();
|
|
@@ -360,6 +449,8 @@ export class CompressionService {
|
|
|
360
449
|
}
|
|
361
450
|
/** Stop an incoming relay, and the channel it feeds. */
|
|
362
451
|
stopPull(id) {
|
|
452
|
+
if (this.relaySource.delete(id))
|
|
453
|
+
return this.channels.stop(id);
|
|
363
454
|
const inbound = this.incoming.get(id);
|
|
364
455
|
if (!inbound)
|
|
365
456
|
return false;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ChannelInfo, PullThrough } from "../channels.ts";
|
|
2
|
+
/** The reason a source cannot be read here, or null when it can. */
|
|
3
|
+
export declare function unreadable(info: ChannelInfo, from: number, input: string[], audio: string): string | null;
|
|
4
|
+
/** Open a plain URL or a file as a stream of bytes, pulled shut by the signal. */
|
|
5
|
+
export declare function openSource(source: string, signal: AbortSignal): Promise<AsyncIterable<Uint8Array>>;
|
|
6
|
+
/** A transport-stream source read by us, for `Channels.setThrough`. */
|
|
7
|
+
export declare function sourceThrough(source: string): PullThrough;
|
|
8
|
+
/**
|
|
9
|
+
* A relay's decoded bytes as an async iterable, at the pace ffmpeg takes
|
|
10
|
+
* them. The decoder is asked to wait when too much is queued, which holds
|
|
11
|
+
* the socket read, which is backpressure all the way to the sender's
|
|
12
|
+
* per-listener queue. A clean end ends the iterable; a broken stream
|
|
13
|
+
* throws, and the channel's own redial dials again.
|
|
14
|
+
*/
|
|
15
|
+
export declare function relayBytes(url: string, key: string | null, signal: AbortSignal): AsyncGenerator<Buffer>;
|
|
16
|
+
/** A source-boundary relay from another nixamp, as a source ffmpeg reads through us. */
|
|
17
|
+
export declare function relayThrough(url: string, key: string | null): PullThrough;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a source ourselves, so its bytes exist here to be relayed.
|
|
3
|
+
*
|
|
4
|
+
* ffmpeg normally dials a channel's source and the original bytes never
|
|
5
|
+
* pass through this process. For a source-boundary relay they have to.
|
|
6
|
+
* This is the narrow set of sources that can be read here and piped to
|
|
7
|
+
* ffmpeg without changing how they play: a transport stream from a plain
|
|
8
|
+
* http(s) URL or a file, from its beginning, with no special request
|
|
9
|
+
* headers and no separate sound file. Anything else is left to ffmpeg,
|
|
10
|
+
* and a relay that asks for its source boundary is told why not.
|
|
11
|
+
*
|
|
12
|
+
* The second half is the receiving end of a source-boundary relay: the
|
|
13
|
+
* decoded original bytes, made into a source ffmpeg can be handed the
|
|
14
|
+
* same way, so a relayed transport stream plays here exactly as the
|
|
15
|
+
* original would have, and can be relayed on again.
|
|
16
|
+
*/
|
|
17
|
+
import { createReadStream, statSync } from "node:fs";
|
|
18
|
+
import { receiveRelay } from "./receiver.js";
|
|
19
|
+
/**
|
|
20
|
+
* ffmpeg input flags that carry per-request authentication a plain fetch
|
|
21
|
+
* would not send: a user agent, a referer, cookies, arbitrary headers. A
|
|
22
|
+
* source that needs any of these is ffmpeg's to dial. Transport demux
|
|
23
|
+
* tuning (`-probesize`, `-analyzeduration`, `-fflags`) is not on this list:
|
|
24
|
+
* it applies to a piped read too and is no reason to refuse the tee.
|
|
25
|
+
*/
|
|
26
|
+
const HEADER_FLAGS = new Set(["-headers", "-user_agent", "-user-agent", "-referer", "-cookies", "-icy", "-http_proxy", "-http_persistent"]);
|
|
27
|
+
/** The reason a source cannot be read here, or null when it can. */
|
|
28
|
+
export function unreadable(info, from, input, audio) {
|
|
29
|
+
const source = info.source ?? "";
|
|
30
|
+
if (info.via !== "pull" || source === "")
|
|
31
|
+
return "only a source this server pulls can be read here";
|
|
32
|
+
const container = info.codecs?.container ?? "";
|
|
33
|
+
if (!container.split(",").includes("mpegts"))
|
|
34
|
+
return `the source is ${container || "of unknown container"}, not a transport stream`;
|
|
35
|
+
if (input.some((arg) => HEADER_FLAGS.has(arg)))
|
|
36
|
+
return "the source needs request headers ffmpeg sends and nixamp does not";
|
|
37
|
+
if (audio !== "")
|
|
38
|
+
return "the source keeps its sound in a second file";
|
|
39
|
+
if (from > 0)
|
|
40
|
+
return "a film picked up mid-way cannot be read from its start";
|
|
41
|
+
if (/^https?:\/\//i.test(source))
|
|
42
|
+
return null;
|
|
43
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(source))
|
|
44
|
+
return "only http(s) URLs and files can be read here";
|
|
45
|
+
try {
|
|
46
|
+
if (!statSync(source).isFile())
|
|
47
|
+
return "the source is not a file";
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return "the source file cannot be read";
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** Open a plain URL or a file as a stream of bytes, pulled shut by the signal. */
|
|
55
|
+
export async function openSource(source, signal) {
|
|
56
|
+
if (/^https?:\/\//i.test(source)) {
|
|
57
|
+
const response = await fetch(source, { signal, redirect: "follow", headers: { "user-agent": "nixamp" } });
|
|
58
|
+
if (!response.ok || !response.body)
|
|
59
|
+
throw new Error(`the source answered ${response.status}`);
|
|
60
|
+
return response.body;
|
|
61
|
+
}
|
|
62
|
+
const stream = createReadStream(source, { highWaterMark: 256 * 1024 });
|
|
63
|
+
signal.addEventListener("abort", () => stream.destroy(), { once: true });
|
|
64
|
+
return stream;
|
|
65
|
+
}
|
|
66
|
+
/** A transport-stream source read by us, for `Channels.setThrough`. */
|
|
67
|
+
export function sourceThrough(source) {
|
|
68
|
+
return { format: "mpegts", open: (signal) => openSource(source, signal) };
|
|
69
|
+
}
|
|
70
|
+
/** How many decoded bytes may wait for ffmpeg before the relay is asked to pause. */
|
|
71
|
+
const RELAY_QUEUE = 8 * 1024 * 1024;
|
|
72
|
+
/**
|
|
73
|
+
* A relay's decoded bytes as an async iterable, at the pace ffmpeg takes
|
|
74
|
+
* them. The decoder is asked to wait when too much is queued, which holds
|
|
75
|
+
* the socket read, which is backpressure all the way to the sender's
|
|
76
|
+
* per-listener queue. A clean end ends the iterable; a broken stream
|
|
77
|
+
* throws, and the channel's own redial dials again.
|
|
78
|
+
*/
|
|
79
|
+
export async function* relayBytes(url, key, signal) {
|
|
80
|
+
const queue = [];
|
|
81
|
+
let queued = 0;
|
|
82
|
+
let wake = null;
|
|
83
|
+
let room = null;
|
|
84
|
+
let done = false;
|
|
85
|
+
let failure = null;
|
|
86
|
+
void receiveRelay({
|
|
87
|
+
url,
|
|
88
|
+
key,
|
|
89
|
+
signal,
|
|
90
|
+
onBytes: async (bytes) => {
|
|
91
|
+
queue.push(bytes);
|
|
92
|
+
queued += bytes.length;
|
|
93
|
+
wake?.();
|
|
94
|
+
wake = null;
|
|
95
|
+
if (queued > RELAY_QUEUE)
|
|
96
|
+
await new Promise((resume) => { room = resume; });
|
|
97
|
+
},
|
|
98
|
+
}).then(() => {
|
|
99
|
+
done = true;
|
|
100
|
+
wake?.();
|
|
101
|
+
}, (error) => {
|
|
102
|
+
failure = error;
|
|
103
|
+
done = true;
|
|
104
|
+
wake?.();
|
|
105
|
+
});
|
|
106
|
+
for (;;) {
|
|
107
|
+
const next = queue.shift();
|
|
108
|
+
if (next) {
|
|
109
|
+
queued -= next.length;
|
|
110
|
+
// Captured into a local: `room` is only ever assigned inside a callback,
|
|
111
|
+
// which the compiler cannot see, so it would narrow the field to null.
|
|
112
|
+
const resume = room;
|
|
113
|
+
if (resume && queued <= RELAY_QUEUE / 2) {
|
|
114
|
+
resume();
|
|
115
|
+
room = null;
|
|
116
|
+
}
|
|
117
|
+
yield next;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (done) {
|
|
121
|
+
if (failure)
|
|
122
|
+
throw failure;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await new Promise((resume) => { wake = resume; });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** A source-boundary relay from another nixamp, as a source ffmpeg reads through us. */
|
|
129
|
+
export function relayThrough(url, key) {
|
|
130
|
+
return { format: "mpegts", open: async (signal) => relayBytes(url, key, signal) };
|
|
131
|
+
}
|
package/dist/links.js
CHANGED
|
@@ -146,6 +146,14 @@ export function parseResolved(json, page) {
|
|
|
146
146
|
}
|
|
147
147
|
if (media === "")
|
|
148
148
|
return null;
|
|
149
|
+
// A link that was the media all along -- an IPTV feed at /channel/906,
|
|
150
|
+
// with no extension to say so -- is answered by yt-dlp's generic
|
|
151
|
+
// extractor with wherever the feed redirected to, which for a panel is a
|
|
152
|
+
// second host and a token minted for that one request. ffmpeg follows a
|
|
153
|
+
// redirect itself, so the link is kept as pasted and every redial gets a
|
|
154
|
+
// fresh token instead of a 403 an hour in.
|
|
155
|
+
if (record["direct"] === true && parts.length === 0 && /^https?:\/\//i.test(page))
|
|
156
|
+
media = page;
|
|
149
157
|
const title = typeof record["title"] === "string" && record["title"].trim() !== ""
|
|
150
158
|
? record["title"].trim().slice(0, 200)
|
|
151
159
|
: fileNameOf(page);
|
|
@@ -269,9 +277,12 @@ export const RESOLVE_TIMEOUT_MS = 60_000;
|
|
|
269
277
|
export async function resolveLink(ytdlp, url, options = {}) {
|
|
270
278
|
if (isDirectMedia(url))
|
|
271
279
|
return directLink(url);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
280
|
+
// Without yt-dlp, a link is taken as the media it may well be: an IPTV
|
|
281
|
+
// feed has no extension and no page behind it, and ffprobe -- asked next,
|
|
282
|
+
// before anything goes on the air -- tells a stream from a web page in a
|
|
283
|
+
// second. A page it cannot read is refused there, by name.
|
|
284
|
+
if (!ytdlp || ytdlp.length === 0)
|
|
285
|
+
return directLink(url);
|
|
275
286
|
const [command, ...prefix] = ytdlp;
|
|
276
287
|
return new Promise((done) => {
|
|
277
288
|
let out = "";
|
package/dist/live-api.js
CHANGED
|
@@ -563,6 +563,7 @@ export async function handleLiveApi(request, response, url, options) {
|
|
|
563
563
|
json(response, error.status, { error: error.message });
|
|
564
564
|
return true;
|
|
565
565
|
}
|
|
566
|
+
console.error(`live api ${request.method} ${path}:`, error instanceof Error ? error.message : error);
|
|
566
567
|
json(response, 500, { error: "the live event service failed" });
|
|
567
568
|
return true;
|
|
568
569
|
}
|
package/dist/live-events.js
CHANGED
|
@@ -71,19 +71,22 @@ const EVENT_SCHEMA = `
|
|
|
71
71
|
ON live_events (visibility, status, starts_at, updated_at DESC);
|
|
72
72
|
CREATE INDEX IF NOT EXISTS live_events_owner
|
|
73
73
|
ON live_events (owner_id, updated_at DESC);
|
|
74
|
-
CREATE INDEX IF NOT EXISTS live_events_kind
|
|
75
|
-
ON live_events (kind, status, starts_at);
|
|
76
74
|
|
|
77
75
|
-- The repo has no migration runner, so a table that already exists is
|
|
78
76
|
-- brought forward here. Every statement is idempotent, and the CHECKs are
|
|
79
77
|
-- replaced by name rather than added twice: the originals were unnamed, so
|
|
80
78
|
-- the ones carrying the old status list are found by what they say.
|
|
79
|
+
-- The columns come before any index on them: nixamp.com had live_events
|
|
80
|
+
-- from before 0.17.0, and an index on kind ahead of ADD COLUMN kind failed
|
|
81
|
+
-- there while every fresh database (tests, CI) sailed through.
|
|
81
82
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'talk';
|
|
82
83
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS doors_open_at TIMESTAMPTZ;
|
|
83
84
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_price_cents INTEGER NOT NULL DEFAULT 0;
|
|
84
85
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_currency TEXT NOT NULL DEFAULT 'USD';
|
|
85
86
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_minutes INTEGER NOT NULL DEFAULT 1440;
|
|
86
87
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS pay_to TEXT;
|
|
88
|
+
CREATE INDEX IF NOT EXISTS live_events_kind
|
|
89
|
+
ON live_events (kind, status, starts_at);
|
|
87
90
|
|
|
88
91
|
DO $$
|
|
89
92
|
DECLARE stale record;
|
|
@@ -402,7 +405,12 @@ export class LiveEvents {
|
|
|
402
405
|
this.db = db;
|
|
403
406
|
}
|
|
404
407
|
async ensure() {
|
|
405
|
-
|
|
408
|
+
// A failed schema run is not remembered: the next request tries again
|
|
409
|
+
// rather than answering 500 until the process restarts.
|
|
410
|
+
this.ready ??= this.db.query(EVENT_SCHEMA).then(() => undefined, (error) => {
|
|
411
|
+
this.ready = null;
|
|
412
|
+
throw error;
|
|
413
|
+
});
|
|
406
414
|
await this.ready;
|
|
407
415
|
}
|
|
408
416
|
async create(ownerId, input) {
|
package/dist/owner.js
CHANGED
|
@@ -136,8 +136,18 @@ export function needsMember(path, method = "GET") {
|
|
|
136
136
|
return true;
|
|
137
137
|
if (method === "POST" && /^\/api\/channels\/[^/]+\/keep$/.test(path))
|
|
138
138
|
return true;
|
|
139
|
+
// A link -- a YouTube page, an IPTV feed, a file somewhere -- is the one
|
|
140
|
+
// thing a member can go live with that needs nothing on the server first,
|
|
141
|
+
// and it was the one thing they could not. Going live with one is the same
|
|
142
|
+
// act as going live with a file here: counted, marked theirs, and capped
|
|
143
|
+
// by the handler. Fetching a link to keep (download) stays the owner's.
|
|
144
|
+
if (method === "POST" && path === "/api/links/play")
|
|
145
|
+
return true;
|
|
139
146
|
// Only their own; the handler checks whose it is.
|
|
140
147
|
if (method === "DELETE" && /^\/api\/channels\/[^/]+$/.test(path))
|
|
141
148
|
return true;
|
|
149
|
+
// Renaming what they put on, likewise.
|
|
150
|
+
if (method === "PATCH" && /^\/api\/channels\/[^/]+$/.test(path))
|
|
151
|
+
return true;
|
|
142
152
|
return false;
|
|
143
153
|
}
|
package/dist/server.js
CHANGED
|
@@ -896,7 +896,10 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
896
896
|
// the timestamps a recording cut mid-stream does not carry. Ahead of the
|
|
897
897
|
// caller's own input arguments, which are headers for the address itself.
|
|
898
898
|
const opening = [...transportInputArgs(source, codecs.container), ...input];
|
|
899
|
-
const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0 }
|
|
899
|
+
const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0 },
|
|
900
|
+
// Known before the first dial: whether the source is a transport stream
|
|
901
|
+
// decides whether it can be read here for a source-boundary relay.
|
|
902
|
+
assumed ? undefined : codecs);
|
|
900
903
|
if (channel && !assumed)
|
|
901
904
|
channel.info.codecs = codecs;
|
|
902
905
|
// What comes out, as opposed to what went in. An H.265 source copied
|
|
@@ -926,18 +929,25 @@ export function cookiesFile() {
|
|
|
926
929
|
return "";
|
|
927
930
|
}
|
|
928
931
|
}
|
|
929
|
-
/**
|
|
930
|
-
|
|
932
|
+
/**
|
|
933
|
+
* What the page is told about a link it asked to play.
|
|
934
|
+
*
|
|
935
|
+
* Whether it is live is the channel's answer where there is one: yt-dlp
|
|
936
|
+
* says nothing about an IPTV feed's liveness, and a feed told to the page
|
|
937
|
+
* as a film got a Download button and a seek bar for a stream with no end.
|
|
938
|
+
*/
|
|
939
|
+
function shownLink(channelId, link, channel) {
|
|
940
|
+
const live = channel?.live ?? link.live;
|
|
931
941
|
return {
|
|
932
942
|
kind: "live",
|
|
933
943
|
channel: channelId,
|
|
934
|
-
name: link.title,
|
|
935
|
-
live
|
|
944
|
+
name: channel?.name ?? link.title,
|
|
945
|
+
live,
|
|
936
946
|
video: link.video,
|
|
937
947
|
duration: link.duration,
|
|
938
948
|
extractor: link.extractor,
|
|
939
949
|
// A live has no whole to keep; a bare file can be fetched by the browser itself.
|
|
940
|
-
download: !
|
|
950
|
+
download: !live && link.extractor !== "direct",
|
|
941
951
|
};
|
|
942
952
|
}
|
|
943
953
|
export function liveOnes(engine) {
|
|
@@ -2646,6 +2656,15 @@ export function createHandler(engine, options) {
|
|
|
2646
2656
|
response.end(JSON.stringify({ match }));
|
|
2647
2657
|
return;
|
|
2648
2658
|
}
|
|
2659
|
+
/*
|
|
2660
|
+
* A link, fetched by this server and carried as a channel.
|
|
2661
|
+
*
|
|
2662
|
+
* Played: started on demand, stopped a minute after the last viewer
|
|
2663
|
+
* leaves. Live (`{ live: true }`, and always for a member): kept,
|
|
2664
|
+
* remembered across a restart, listed in the directory with a room
|
|
2665
|
+
* code, and -- for a member -- marked theirs and counted against what
|
|
2666
|
+
* they may have on at once, exactly like a file from the library.
|
|
2667
|
+
*/
|
|
2649
2668
|
if (path === "/api/links/play" && request.method === "POST") {
|
|
2650
2669
|
let body = {};
|
|
2651
2670
|
try {
|
|
@@ -2666,11 +2685,54 @@ export function createHandler(engine, options) {
|
|
|
2666
2685
|
}
|
|
2667
2686
|
const channelId = linkChannelId(link);
|
|
2668
2687
|
const known = links.get(link);
|
|
2688
|
+
// A member's is always a live: a decoder started on somebody else's
|
|
2689
|
+
// machine is theirs to own, not a thing left running anonymously.
|
|
2690
|
+
const goLive = body.live === true || liveBy !== "";
|
|
2691
|
+
/**
|
|
2692
|
+
* On the air and kept, as going live means; for a member, theirs.
|
|
2693
|
+
* Answers "" or a refusal. `fresh` is a channel this very request
|
|
2694
|
+
* started, which is nobody's yet and was counted before it was.
|
|
2695
|
+
*/
|
|
2696
|
+
const putOnTheAir = (fresh) => {
|
|
2697
|
+
const channels = options.channels;
|
|
2698
|
+
const info = channels.info(channelId);
|
|
2699
|
+
if (!info)
|
|
2700
|
+
return "";
|
|
2701
|
+
if (liveBy && !info.startedBy) {
|
|
2702
|
+
// Somebody's already, and not started on demand: the owner's.
|
|
2703
|
+
if (!fresh && !channels.isEphemeral(channelId))
|
|
2704
|
+
return "that stream is the owner's";
|
|
2705
|
+
if (!fresh) {
|
|
2706
|
+
const refusal = memberLiveRefusal(channels, liveBy);
|
|
2707
|
+
if (refusal)
|
|
2708
|
+
return refusal;
|
|
2709
|
+
}
|
|
2710
|
+
info.startedBy = liveBy;
|
|
2711
|
+
}
|
|
2712
|
+
channels.keep(channelId);
|
|
2713
|
+
options.rememberChannels?.(rememberedNow(channels));
|
|
2714
|
+
return "";
|
|
2715
|
+
};
|
|
2669
2716
|
if (options.channels.has(channelId) && known) {
|
|
2670
|
-
|
|
2717
|
+
if (goLive) {
|
|
2718
|
+
const refusal = putOnTheAir(false);
|
|
2719
|
+
if (refusal) {
|
|
2720
|
+
json(response, refusal.endsWith("owner's") ? 403 : 429, { error: refusal });
|
|
2721
|
+
return;
|
|
2722
|
+
}
|
|
2723
|
+
void options.live?.announce?.();
|
|
2724
|
+
}
|
|
2725
|
+
json(response, 200, shownLink(channelId, known, options.channels.info(channelId)));
|
|
2671
2726
|
return;
|
|
2672
2727
|
}
|
|
2673
|
-
if (!options.channels.has(channelId) &&
|
|
2728
|
+
if (!options.channels.has(channelId) && liveBy) {
|
|
2729
|
+
const refusal = memberLiveRefusal(options.channels, liveBy);
|
|
2730
|
+
if (refusal) {
|
|
2731
|
+
json(response, 429, { error: refusal });
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
if (!options.channels.has(channelId) && !goLive && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
|
|
2674
2736
|
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
2675
2737
|
return;
|
|
2676
2738
|
}
|
|
@@ -2699,12 +2761,23 @@ export function createHandler(engine, options) {
|
|
|
2699
2761
|
json(response, 409, { error: "that link is already starting" });
|
|
2700
2762
|
return;
|
|
2701
2763
|
}
|
|
2702
|
-
|
|
2764
|
+
if (goLive)
|
|
2765
|
+
putOnTheAir(true);
|
|
2766
|
+
else
|
|
2767
|
+
options.channels.ephemeral(channelId);
|
|
2703
2768
|
// Told to the directory now, so the room code arrives with the
|
|
2704
2769
|
// channel rather than at the next heartbeat, ninety seconds on.
|
|
2705
2770
|
void options.live?.announce?.();
|
|
2706
2771
|
}
|
|
2707
|
-
|
|
2772
|
+
else if (goLive) {
|
|
2773
|
+
const refusal = putOnTheAir(false);
|
|
2774
|
+
if (refusal) {
|
|
2775
|
+
json(response, refusal.endsWith("owner's") ? 403 : 429, { error: refusal });
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
void options.live?.announce?.();
|
|
2779
|
+
}
|
|
2780
|
+
json(response, 200, shownLink(channelId, resolved, options.channels.info(channelId)));
|
|
2708
2781
|
return;
|
|
2709
2782
|
}
|
|
2710
2783
|
// The whole thing, to keep. The server fetches it through yt-dlp and
|
|
@@ -2970,6 +3043,41 @@ export function createHandler(engine, options) {
|
|
|
2970
3043
|
response.on("close", leave);
|
|
2971
3044
|
return;
|
|
2972
3045
|
}
|
|
3046
|
+
/*
|
|
3047
|
+
* Rename what is on the air. A link goes on as "906" or whatever the
|
|
3048
|
+
* file was called, and the directory lists that; the person who put it
|
|
3049
|
+
* on knows what it is. The owner renames anything; a member, their own.
|
|
3050
|
+
*/
|
|
3051
|
+
if (action === undefined && request.method === "PATCH") {
|
|
3052
|
+
const info = channels.info(id);
|
|
3053
|
+
if (!info) {
|
|
3054
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
if (liveBy && info.startedBy !== liveBy) {
|
|
3058
|
+
json(response, 403, { error: "that stream is not yours to rename" });
|
|
3059
|
+
return;
|
|
3060
|
+
}
|
|
3061
|
+
let body = {};
|
|
3062
|
+
try {
|
|
3063
|
+
body = JSON.parse(await readBody(request));
|
|
3064
|
+
}
|
|
3065
|
+
catch {
|
|
3066
|
+
json(response, 400, { error: "bad JSON" });
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
const name = typeof body.name === "string" ? body.name.replace(/[\r\n\t]+/g, " ").trim().slice(0, 120) : "";
|
|
3070
|
+
if (name === "") {
|
|
3071
|
+
json(response, 400, { error: "a name is needed" });
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
info.name = name;
|
|
3075
|
+
options.rememberChannels?.(rememberedNow(channels));
|
|
3076
|
+
// Listed under the new name now, not at the next heartbeat.
|
|
3077
|
+
void options.live?.announce?.();
|
|
3078
|
+
json(response, 200, { ok: true, channel: id, name });
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
2973
3081
|
if (action === undefined && request.method === "DELETE") {
|
|
2974
3082
|
// A member takes off what they put on, and nothing else.
|
|
2975
3083
|
if (liveBy && channels.info(id)?.startedBy !== liveBy) {
|
package/dist/share.js
CHANGED
|
@@ -335,6 +335,12 @@ export function allowedForListening(path) {
|
|
|
335
335
|
// Having the server fetch a link is a decoder and a download on somebody
|
|
336
336
|
// else's machine: the control link's to ask for, not the listen link's. A
|
|
337
337
|
// viewer's link plays in their own browser.
|
|
338
|
+
// Putting a link on the air is the exception: a member reaches a server
|
|
339
|
+
// from nixamp.com with its listen link and their session, and the
|
|
340
|
+
// administering gate behind this one decides whether that session is a
|
|
341
|
+
// member's. A listen key alone still gets a 403 there.
|
|
342
|
+
if (path === "/api/links/play")
|
|
343
|
+
return true;
|
|
338
344
|
if (path === "/api/links" || path.startsWith("/api/links/"))
|
|
339
345
|
return false;
|
|
340
346
|
return true;
|