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
package/src/compression/cli.ts
CHANGED
|
@@ -16,11 +16,14 @@
|
|
|
16
16
|
* `nixamp admin` finds its target. JSON goes to stdout and only JSON;
|
|
17
17
|
* progress and complaints go to stderr; a failure is a nonzero exit.
|
|
18
18
|
*/
|
|
19
|
-
import { createWriteStream } from "node:fs";
|
|
19
|
+
import { createWriteStream, mkdirSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
20
21
|
import { resolveTarget } from "../admin.ts";
|
|
21
22
|
import { detectTools } from "../audio.ts";
|
|
23
|
+
import { version as nixampVersion } from "../meta.ts";
|
|
22
24
|
import { KEY_HEADER } from "../share.ts";
|
|
23
25
|
import type { Analysis } from "./analyze.ts";
|
|
26
|
+
import { exactnessFailures, fileCorpus, reportMarkdown, reportPasses, runBenchmark, type Sample, syntheticCorpus } from "./benchmark.ts";
|
|
24
27
|
import { RelayError } from "./envelope.ts";
|
|
25
28
|
import type { Job } from "./jobs.ts";
|
|
26
29
|
import { receiveRelay, RelayRefused } from "./receiver.ts";
|
|
@@ -36,6 +39,8 @@ const USAGE = `nixamp compression — lossless relay compression: measure it, se
|
|
|
36
39
|
nixamp compression off | on the whole server's switch
|
|
37
40
|
nixamp compression pull --channel ID --from URL [--from-key KEY] [--name NAME]
|
|
38
41
|
nixamp compression fetch URL --out FILE [--key KEY]
|
|
42
|
+
nixamp compression benchmark [--corpus DIR] [--out DIR] [--levels 1,3,9]
|
|
43
|
+
prove the envelope on a corpus and write a report; no server or ffmpeg needed
|
|
39
44
|
|
|
40
45
|
--url U --key K a server other than the local daemon, as for \`nixamp admin\`
|
|
41
46
|
--format json JSON on stdout (the default when stdout is not a terminal)
|
|
@@ -309,6 +314,53 @@ async function fetchRelay(flags: Flags): Promise<number> {
|
|
|
309
314
|
}
|
|
310
315
|
}
|
|
311
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Run the OpenStream benchmark and write a report. Anyone can run it: the
|
|
319
|
+
* default corpus is bytes we generate, no server and no ffmpeg. `--corpus DIR`
|
|
320
|
+
* benchmarks a directory of real files instead; `--out DIR` writes
|
|
321
|
+
* `openstream-report.json` and `.md` there for publishing.
|
|
322
|
+
*/
|
|
323
|
+
async function bench(flags: Flags): Promise<number> {
|
|
324
|
+
const dir = flags.named.get("corpus");
|
|
325
|
+
let corpus: Sample[];
|
|
326
|
+
try {
|
|
327
|
+
corpus = dir ? fileCorpus(dir) : syntheticCorpus();
|
|
328
|
+
} catch (error) {
|
|
329
|
+
console.error(`nixamp: cannot read corpus ${dir}: ${(error as Error).message}`);
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
if (corpus.length === 0) {
|
|
333
|
+
console.error(`nixamp: no samples${dir ? ` in ${dir}` : ""}`);
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
console.error(` Benchmarking ${corpus.length} sample${corpus.length === 1 ? "" : "s"}${dir ? ` from ${dir}` : " (synthetic)"}…`);
|
|
337
|
+
const levels = flags.named.get("levels");
|
|
338
|
+
const report = await runBenchmark(corpus, {
|
|
339
|
+
implementationVersion: nixampVersion(),
|
|
340
|
+
...(levels ? { zstdLevels: levels.split(",").map((n) => Number(n)).filter((n) => Number.isInteger(n)) } : {}),
|
|
341
|
+
});
|
|
342
|
+
const out = flags.named.get("out");
|
|
343
|
+
if (out) {
|
|
344
|
+
try {
|
|
345
|
+
mkdirSync(out, { recursive: true });
|
|
346
|
+
writeFileSync(join(out, "openstream-report.json"), JSON.stringify(report, null, 2));
|
|
347
|
+
writeFileSync(join(out, "openstream-report.md"), reportMarkdown(report));
|
|
348
|
+
console.error(` Wrote openstream-report.json and .md to ${out}`);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
console.error(`nixamp: cannot write to ${out}: ${(error as Error).message}`);
|
|
351
|
+
return 1;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (wantsJson(flags)) console.log(JSON.stringify(report, null, 2));
|
|
355
|
+
else console.log(reportMarkdown(report));
|
|
356
|
+
if (!reportPasses(report)) {
|
|
357
|
+
const bad = exactnessFailures(report).map((f) => `${f.sample}/${f.mode}${f.level ? `L${f.level}` : ""}`).join(", ");
|
|
358
|
+
console.error(`nixamp: these did not restore byte-for-byte, this build must not ship: ${bad}`);
|
|
359
|
+
return 1;
|
|
360
|
+
}
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
312
364
|
export async function compression(argv: string[]): Promise<number> {
|
|
313
365
|
const flags = parse(argv);
|
|
314
366
|
const verb = flags.positional[0];
|
|
@@ -329,6 +381,9 @@ export async function compression(argv: string[]): Promise<number> {
|
|
|
329
381
|
return await pull(flags);
|
|
330
382
|
case "fetch":
|
|
331
383
|
return await fetchRelay(flags);
|
|
384
|
+
case "benchmark":
|
|
385
|
+
case "bench":
|
|
386
|
+
return await bench(flags);
|
|
332
387
|
default:
|
|
333
388
|
console.error(USAGE);
|
|
334
389
|
return verb === undefined || verb === "help" || flags.named.has("help") ? 0 : 2;
|
|
@@ -10,13 +10,78 @@
|
|
|
10
10
|
* which is surfaced as an error naming the reason, never as a stream of
|
|
11
11
|
* something else.
|
|
12
12
|
*/
|
|
13
|
-
import { MEDIA_TYPE, type Mode, RelayError, type StreamHeader } from "./envelope.ts";
|
|
13
|
+
import { type Boundary, MEDIA_TYPE, type Mode, RelayError, type StreamHeader } from "./envelope.ts";
|
|
14
14
|
import { RelayDecoder } from "./relay.ts";
|
|
15
15
|
|
|
16
16
|
export const CODECS_HEADER = "x-nixamp-stream-codecs";
|
|
17
17
|
export const KIND_HEADER = "x-nixamp-kind";
|
|
18
|
+
export const BOUNDARY_HEADER = "x-nixamp-boundary";
|
|
19
|
+
/** What the media inside is, as JSON: video, audio, container, duration. */
|
|
20
|
+
export const MEDIA_HEADER = "x-nixamp-codecs";
|
|
18
21
|
export const KEY_HEADER = "x-nixamp-key";
|
|
19
22
|
|
|
23
|
+
export interface Media {
|
|
24
|
+
video: string;
|
|
25
|
+
audio: string;
|
|
26
|
+
container: string;
|
|
27
|
+
duration?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a server said it would send, read off its response headers. */
|
|
31
|
+
export interface Accepted {
|
|
32
|
+
codecs: string;
|
|
33
|
+
kind: "audio" | "video" | "";
|
|
34
|
+
boundary: Boundary | "";
|
|
35
|
+
media: Media | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function acceptedFrom(headers: Headers): Accepted {
|
|
39
|
+
const kindSaid = headers.get(KIND_HEADER);
|
|
40
|
+
const boundarySaid = headers.get(BOUNDARY_HEADER);
|
|
41
|
+
let media: Media | null = null;
|
|
42
|
+
try {
|
|
43
|
+
const raw = JSON.parse(headers.get(MEDIA_HEADER) ?? "null") as Partial<Media> | null;
|
|
44
|
+
if (raw && typeof raw.video === "string" && typeof raw.audio === "string" && typeof raw.container === "string") {
|
|
45
|
+
media = { video: raw.video, audio: raw.audio, container: raw.container };
|
|
46
|
+
if (typeof raw.duration === "number") media.duration = raw.duration;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Not JSON: no media description, which the receiver copes with.
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
codecs: headers.get(CODECS_HEADER) ?? "",
|
|
53
|
+
kind: kindSaid === "audio" || kindSaid === "video" ? kindSaid : "",
|
|
54
|
+
boundary: boundarySaid === "source" || boundarySaid === "channel" ? boundarySaid : "",
|
|
55
|
+
media,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ask a relay what it would send, without taking it: the negotiation
|
|
61
|
+
* headers come back on the response and the body is cancelled at once.
|
|
62
|
+
* Costs one short connection; a receiver needs to know the boundary and
|
|
63
|
+
* the media before it can decide how to carry the stream.
|
|
64
|
+
*/
|
|
65
|
+
export async function probeRelay(url: string, key: string | null, fetchImpl?: typeof fetch): Promise<Accepted> {
|
|
66
|
+
const headers: Record<string, string> = { accept: MEDIA_TYPE, [CODECS_HEADER]: "stored,zstd,ts-zstd" };
|
|
67
|
+
if (key) headers[KEY_HEADER] = key;
|
|
68
|
+
const response = await (fetchImpl ?? fetch)(url, { headers });
|
|
69
|
+
const type = response.headers.get("content-type") ?? "";
|
|
70
|
+
if (response.status !== 200 || !type.startsWith(MEDIA_TYPE)) {
|
|
71
|
+
let reason = `${response.status}`;
|
|
72
|
+
try {
|
|
73
|
+
const body = (await response.json()) as { error?: string };
|
|
74
|
+
if (typeof body.error === "string") reason = body.error;
|
|
75
|
+
} catch {
|
|
76
|
+
// Not JSON; the status is the message.
|
|
77
|
+
}
|
|
78
|
+
throw new RelayRefused(response.status, reason);
|
|
79
|
+
}
|
|
80
|
+
const accepted = acceptedFrom(response.headers);
|
|
81
|
+
await response.body?.cancel().catch(() => undefined);
|
|
82
|
+
return accepted;
|
|
83
|
+
}
|
|
84
|
+
|
|
20
85
|
export interface ReceiveOptions {
|
|
21
86
|
url: string;
|
|
22
87
|
key: string | null;
|
|
@@ -24,7 +89,7 @@ export interface ReceiveOptions {
|
|
|
24
89
|
modes?: Mode[];
|
|
25
90
|
maxFrameBytes?: number;
|
|
26
91
|
/** The server said yes: what it will compress with, and what the channel carries. Before any byte. */
|
|
27
|
-
onStart?: (accepted:
|
|
92
|
+
onStart?: (accepted: Accepted) => void;
|
|
28
93
|
/** The stream header arrived: the generation this is. */
|
|
29
94
|
onHeader?: (header: StreamHeader) => void;
|
|
30
95
|
onBytes: (bytes: Buffer) => void | Promise<void>;
|
|
@@ -74,11 +139,7 @@ export async function receiveRelay(options: ReceiveOptions): Promise<ReceiveResu
|
|
|
74
139
|
throw new RelayRefused(response.status, reason);
|
|
75
140
|
}
|
|
76
141
|
if (!response.body) throw new RelayRefused(response.status, "no body");
|
|
77
|
-
|
|
78
|
-
options.onStart?.({
|
|
79
|
-
codecs: response.headers.get(CODECS_HEADER) ?? "",
|
|
80
|
-
kind: kindSaid === "audio" || kindSaid === "video" ? kindSaid : "",
|
|
81
|
-
});
|
|
142
|
+
options.onStart?.(acceptedFrom(response.headers));
|
|
82
143
|
const decoder = new RelayDecoder({
|
|
83
144
|
modes,
|
|
84
145
|
...(options.maxFrameBytes !== undefined ? { maxFrameBytes: options.maxFrameBytes } : {}),
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { createReadStream } from "node:fs";
|
|
13
13
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
14
14
|
import { MEDIA_TYPE, parseModes } from "./envelope.ts";
|
|
15
|
-
import { CODECS_HEADER, KIND_HEADER } from "./receiver.ts";
|
|
15
|
+
import { BOUNDARY_HEADER, CODECS_HEADER, KIND_HEADER, MEDIA_HEADER } from "./receiver.ts";
|
|
16
16
|
import type { CompressionService } from "./service.ts";
|
|
17
17
|
|
|
18
18
|
export interface RouteContext {
|
|
@@ -140,6 +140,11 @@ export async function handleChannelCompression(
|
|
|
140
140
|
"content-encoding": "identity",
|
|
141
141
|
[CODECS_HEADER]: answer.codecs.join(","),
|
|
142
142
|
[KIND_HEADER]: answer.kind,
|
|
143
|
+
// Which bytes these are, and what is inside them: a receiver of the
|
|
144
|
+
// source boundary hands them to its own ffmpeg and needs to know
|
|
145
|
+
// what ffmpeg will find, without a probe of its own.
|
|
146
|
+
[BOUNDARY_HEADER]: answer.boundary,
|
|
147
|
+
...(answer.media ? { [MEDIA_HEADER]: JSON.stringify(answer.media) } : {}),
|
|
143
148
|
"x-nixamp-generation": String(answer.generation),
|
|
144
149
|
});
|
|
145
150
|
const leave = (): void => answer.session.leave();
|
|
@@ -160,7 +165,7 @@ export async function handleChannelCompression(
|
|
|
160
165
|
}
|
|
161
166
|
const key = typeof body?.["key"] === "string" ? body["key"] : null;
|
|
162
167
|
const name = typeof body?.["name"] === "string" ? body["name"] : id;
|
|
163
|
-
const started = service.pull(id, from, key, name);
|
|
168
|
+
const started = await service.pull(id, from, key, name);
|
|
164
169
|
if (!started.ok) {
|
|
165
170
|
json(response, started.status, { error: started.error });
|
|
166
171
|
return true;
|
|
@@ -8,16 +8,17 @@
|
|
|
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.ts";
|
|
12
|
-
import { type Channel, type Channels, GIVE_UP, REDIAL } from "../channels.ts";
|
|
11
|
+
import { codecsOf, videoArgs } from "../audio.ts";
|
|
12
|
+
import { type Channel, type ChannelInfo, type Channels, GIVE_UP, type PullThrough, REDIAL } from "../channels.ts";
|
|
13
13
|
import { type Analysis, analyzeSample, SAMPLE_MAX_BYTES, SAMPLE_MAX_SECONDS } from "./analyze.ts";
|
|
14
14
|
import { Pool } from "./codec.ts";
|
|
15
|
-
import { type Mode, RelayError } from "./envelope.ts";
|
|
15
|
+
import { type Boundary, type Mode, RelayError } from "./envelope.ts";
|
|
16
16
|
import { AnalysisJobs, type Job } from "./jobs.ts";
|
|
17
17
|
import { ChannelMetrics, type ChannelMetricsSnapshot } from "./metrics.ts";
|
|
18
18
|
import { type ChannelPolicy, type LosslessPolicy, variantOf } from "./policy.ts";
|
|
19
|
-
import { receiveRelay, RelayRefused } from "./receiver.ts";
|
|
19
|
+
import { type Accepted, probeRelay, receiveRelay, RelayRefused } from "./receiver.ts";
|
|
20
20
|
import { RelayEncoder, type RelayListener, type RelaySession } from "./relay.ts";
|
|
21
|
+
import { relayThrough, sourceThrough, unreadable } from "./source.ts";
|
|
21
22
|
import { type GlobalSettings, PolicyStore } from "./store.ts";
|
|
22
23
|
import { type Prepared, StaticCache } from "./static.ts";
|
|
23
24
|
|
|
@@ -101,7 +102,16 @@ export interface ChannelStatus {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
export type RelayAnswer =
|
|
104
|
-
| {
|
|
105
|
+
| {
|
|
106
|
+
ok: true;
|
|
107
|
+
session: RelaySession;
|
|
108
|
+
codecs: Mode[];
|
|
109
|
+
generation: number;
|
|
110
|
+
kind: "audio" | "video" | "";
|
|
111
|
+
/** Which bytes the stream carries, and what is inside them. */
|
|
112
|
+
boundary: Boundary;
|
|
113
|
+
media: ChannelInfo["codecs"] | null;
|
|
114
|
+
}
|
|
105
115
|
| { ok: false; status: 404 | 406 | 409 | 503; code: string; error: string };
|
|
106
116
|
|
|
107
117
|
interface Running {
|
|
@@ -110,7 +120,17 @@ interface Running {
|
|
|
110
120
|
variant: string;
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* An incoming relay: this server as the receiver.
|
|
125
|
+
*
|
|
126
|
+
* Two shapes, decided by what the sender says it carries. A channel-boundary
|
|
127
|
+
* relay is the sender's finished output, so it is fed straight into a
|
|
128
|
+
* channel here and dialled again by this class when it drops. A
|
|
129
|
+
* source-boundary relay is the original transport stream, so it is handed
|
|
130
|
+
* to a channel's own ffmpeg as a source read through us -- which plays it
|
|
131
|
+
* exactly as the sender would have, lets it be relayed on again, and puts
|
|
132
|
+
* the redialling in the channel where it already lives.
|
|
133
|
+
*/
|
|
114
134
|
class Incoming {
|
|
115
135
|
generation = 0;
|
|
116
136
|
reconnects = 0;
|
|
@@ -223,8 +243,24 @@ export class CompressionService {
|
|
|
223
243
|
this.statics = options.cacheDir
|
|
224
244
|
? new StaticCache(options.cacheDir, { pool: this.pool, ...(options.maxCacheBytes !== undefined ? { maxBytes: options.maxCacheBytes } : {}) })
|
|
225
245
|
: null;
|
|
246
|
+
// Whether a pulled source is read here rather than by ffmpeg, decided at
|
|
247
|
+
// every dial: only when the channel's policy asks for the source boundary
|
|
248
|
+
// and the source is one we can read exactly (a plain transport stream).
|
|
249
|
+
// A channel we brought in from another nixamp's source-boundary relay is
|
|
250
|
+
// itself read through us, so it can be relayed on.
|
|
251
|
+
this.channels.setThrough((info, from, input, audio) => {
|
|
252
|
+
const relayed = this.relaySource.get(info.id);
|
|
253
|
+
if (relayed) return relayThrough(relayed.from, relayed.key);
|
|
254
|
+
const policy = this.store.get(info.id).losslessCompression;
|
|
255
|
+
if (policy.mode === "off" || policy.boundary !== "source") return null;
|
|
256
|
+
if (unreadable(info, from, input, audio) !== null) return null;
|
|
257
|
+
return sourceThrough(info.source ?? "");
|
|
258
|
+
});
|
|
226
259
|
}
|
|
227
260
|
|
|
261
|
+
/** Incoming source-boundary relays, so their channel is read through us. */
|
|
262
|
+
private readonly relaySource = new Map<string, { from: string; key: string | null }>();
|
|
263
|
+
|
|
228
264
|
private get channels(): Channels {
|
|
229
265
|
return this.options.channels;
|
|
230
266
|
}
|
|
@@ -239,8 +275,15 @@ export class CompressionService {
|
|
|
239
275
|
lossless.mode = "off";
|
|
240
276
|
reason = "compression is off for the whole server";
|
|
241
277
|
} else if (lossless.mode !== "off" && lossless.boundary === "source") {
|
|
242
|
-
|
|
243
|
-
|
|
278
|
+
// The source boundary is available only for a source we can read here.
|
|
279
|
+
// A live channel that is not read through us cannot offer it; a channel
|
|
280
|
+
// that is not on yet is given the benefit of the doubt until it is.
|
|
281
|
+
const info = this.channels.info(id);
|
|
282
|
+
const why = info ? unreadable(info, 0, [], "") : null;
|
|
283
|
+
if (why !== null) {
|
|
284
|
+
lossless.mode = "off";
|
|
285
|
+
reason = `original source bytes are unavailable: ${why}`;
|
|
286
|
+
}
|
|
244
287
|
}
|
|
245
288
|
return {
|
|
246
289
|
losslessCompression: lossless,
|
|
@@ -333,7 +376,9 @@ export class CompressionService {
|
|
|
333
376
|
}
|
|
334
377
|
// A receiver that cannot undo the transform gets a variant without it.
|
|
335
378
|
const variantPolicy: LosslessPolicy = { ...policy, tsAware: policy.tsAware && codecs.includes("ts-zstd") };
|
|
336
|
-
|
|
379
|
+
// The boundary is part of the variant: a source relay and a channel
|
|
380
|
+
// relay are two different streams, and two different compressors.
|
|
381
|
+
const variant = `${policy.boundary}:${variantOf(variantPolicy)}`;
|
|
337
382
|
let running = this.running.get(id);
|
|
338
383
|
if (running && running.variant !== variant) {
|
|
339
384
|
// One compressor per channel. A second variant would be a second
|
|
@@ -341,17 +386,32 @@ export class CompressionService {
|
|
|
341
386
|
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" };
|
|
342
387
|
}
|
|
343
388
|
if (!running) {
|
|
344
|
-
const started = this.startEncoder(id, variantPolicy, variant);
|
|
345
|
-
if (!started)
|
|
389
|
+
const started = this.startEncoder(id, variantPolicy, variant, policy.boundary);
|
|
390
|
+
if (!started) {
|
|
391
|
+
return started === null
|
|
392
|
+
? { ok: false, status: 503, code: "CHANNEL_GONE", error: "the channel ended before the relay could start" }
|
|
393
|
+
: { 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" };
|
|
394
|
+
}
|
|
346
395
|
running = started;
|
|
347
396
|
}
|
|
348
|
-
//
|
|
349
|
-
|
|
397
|
+
// For the channel boundary, the opening bytes are the preface. The source
|
|
398
|
+
// boundary has none: a joining demuxer re-syncs on the next PAT and
|
|
399
|
+
// keyframe, exactly as it does when a live source is joined.
|
|
400
|
+
const preface = policy.boundary === "source" ? [] : this.channels.opening(id);
|
|
350
401
|
const session = running.encoder.join(listener, preface);
|
|
351
|
-
return {
|
|
402
|
+
return {
|
|
403
|
+
ok: true,
|
|
404
|
+
session,
|
|
405
|
+
codecs,
|
|
406
|
+
generation: running.encoder.generation,
|
|
407
|
+
kind: this.channels.kindOf(id) ?? "",
|
|
408
|
+
boundary: policy.boundary,
|
|
409
|
+
media: this.channels.info(id)?.codecs ?? null,
|
|
410
|
+
};
|
|
352
411
|
}
|
|
353
412
|
|
|
354
|
-
|
|
413
|
+
/** Null: the channel ended. False: the source boundary was asked for but is not being read through us. */
|
|
414
|
+
private startEncoder(id: string, policy: LosslessPolicy, variant: string, boundary: Boundary): Running | null | false {
|
|
355
415
|
this.generation = (this.generation + 1) % 0xffff_ffff;
|
|
356
416
|
const metrics = this.metrics.get(id) ?? new ChannelMetrics();
|
|
357
417
|
this.metrics.set(id, metrics);
|
|
@@ -361,7 +421,7 @@ export class CompressionService {
|
|
|
361
421
|
policy,
|
|
362
422
|
pool: this.pool,
|
|
363
423
|
metrics,
|
|
364
|
-
boundary
|
|
424
|
+
boundary,
|
|
365
425
|
onAbort: () => {
|
|
366
426
|
if (record && this.running.get(id) === record) {
|
|
367
427
|
this.running.delete(id);
|
|
@@ -378,22 +438,58 @@ export class CompressionService {
|
|
|
378
438
|
}
|
|
379
439
|
},
|
|
380
440
|
});
|
|
381
|
-
const
|
|
382
|
-
write: (chunk) => encoder.write(chunk),
|
|
383
|
-
end: () => encoder.end(),
|
|
384
|
-
pending: () => metrics.queueBytes,
|
|
385
|
-
}
|
|
386
|
-
|
|
441
|
+
const face = {
|
|
442
|
+
write: (chunk: Buffer): boolean => encoder.write(chunk),
|
|
443
|
+
end: (): void => encoder.end(),
|
|
444
|
+
pending: (): number => metrics.queueBytes,
|
|
445
|
+
};
|
|
446
|
+
// The source boundary taps the source's own bytes; the channel boundary
|
|
447
|
+
// is one more ordinary listener on the channel's output.
|
|
448
|
+
const detach = boundary === "source" ? this.channels.tapSource(id, face) : this.channels.listen(id, face);
|
|
449
|
+
if (detach === null) return boundary === "source" ? false : null;
|
|
387
450
|
encoder.attach();
|
|
388
451
|
record = { encoder, detach, variant };
|
|
389
452
|
this.running.set(id, record);
|
|
390
453
|
return record;
|
|
391
454
|
}
|
|
392
455
|
|
|
393
|
-
/**
|
|
394
|
-
|
|
456
|
+
/**
|
|
457
|
+
* Start listening to another nixamp's channel as one of ours. Probes the
|
|
458
|
+
* relay first to learn its boundary: a channel-boundary relay is decoded
|
|
459
|
+
* and its bytes are the channel's output directly; a source-boundary relay
|
|
460
|
+
* is the original transport stream, so it is read through ffmpeg here
|
|
461
|
+
* exactly as a pulled source would be, and can be relayed on again.
|
|
462
|
+
*/
|
|
463
|
+
async pull(id: string, from: string, key: string | null, name: string): Promise<{ ok: true } | { ok: false; status: 409 | 400 | 502; error: string }> {
|
|
395
464
|
if (!/^https?:\/\//i.test(from)) return { ok: false, status: 400, error: "the relay address must be http or https" };
|
|
396
465
|
if (this.channels.has(id) || this.incoming.has(id)) return { ok: false, status: 409, error: `channel "${id}" is already on` };
|
|
466
|
+
let accepted: Accepted;
|
|
467
|
+
try {
|
|
468
|
+
accepted = await probeRelay(from, key);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
const message = error instanceof RelayRefused ? `the relay refused: ${error.message}` : (error as Error).message;
|
|
471
|
+
return { ok: false, status: 502, error: message };
|
|
472
|
+
}
|
|
473
|
+
if (accepted.boundary === "source") {
|
|
474
|
+
// The upstream sends the original transport stream. Read it through
|
|
475
|
+
// ffmpeg here: the through-provider sees this registration and hands
|
|
476
|
+
// ffmpeg the decoded bytes. It can then be relayed on at either
|
|
477
|
+
// boundary, since its source now passes through this process.
|
|
478
|
+
const media = accepted.media;
|
|
479
|
+
const kind: "audio" | "video" = accepted.kind === "audio" ? "audio" : "video";
|
|
480
|
+
const encode = kind === "video"
|
|
481
|
+
? videoArgs(media ?? { video: "h264", audio: "aac", container: "mpegts" })
|
|
482
|
+
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
483
|
+
this.relaySource.set(id, { from, key });
|
|
484
|
+
// A placeholder source string: the through-provider supplies the bytes,
|
|
485
|
+
// so this is only what a listener would never see and a restart reads.
|
|
486
|
+
const channel = this.channels.pull(id, name, from, encode, kind, true, undefined, [], "", { live: true, position: 0 }, media ?? undefined);
|
|
487
|
+
if (!channel) {
|
|
488
|
+
this.relaySource.delete(id);
|
|
489
|
+
return { ok: false, status: 409, error: `channel "${id}" is already on` };
|
|
490
|
+
}
|
|
491
|
+
return { ok: true };
|
|
492
|
+
}
|
|
397
493
|
const inbound = new Incoming(id, from, key, name, this.channels, this.options.onEvent ?? (() => undefined), (gone) => this.incoming.delete(gone));
|
|
398
494
|
this.incoming.set(id, inbound);
|
|
399
495
|
inbound.start();
|
|
@@ -402,6 +498,7 @@ export class CompressionService {
|
|
|
402
498
|
|
|
403
499
|
/** Stop an incoming relay, and the channel it feeds. */
|
|
404
500
|
stopPull(id: string): boolean {
|
|
501
|
+
if (this.relaySource.delete(id)) return this.channels.stop(id);
|
|
405
502
|
const inbound = this.incoming.get(id);
|
|
406
503
|
if (!inbound) return false;
|
|
407
504
|
inbound.stop();
|
|
@@ -0,0 +1,130 @@
|
|
|
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 type { ChannelInfo, PullThrough } from "../channels.ts";
|
|
19
|
+
import { receiveRelay } from "./receiver.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* ffmpeg input flags that carry per-request authentication a plain fetch
|
|
23
|
+
* would not send: a user agent, a referer, cookies, arbitrary headers. A
|
|
24
|
+
* source that needs any of these is ffmpeg's to dial. Transport demux
|
|
25
|
+
* tuning (`-probesize`, `-analyzeduration`, `-fflags`) is not on this list:
|
|
26
|
+
* it applies to a piped read too and is no reason to refuse the tee.
|
|
27
|
+
*/
|
|
28
|
+
const HEADER_FLAGS = new Set(["-headers", "-user_agent", "-user-agent", "-referer", "-cookies", "-icy", "-http_proxy", "-http_persistent"]);
|
|
29
|
+
|
|
30
|
+
/** The reason a source cannot be read here, or null when it can. */
|
|
31
|
+
export function unreadable(info: ChannelInfo, from: number, input: string[], audio: string): string | null {
|
|
32
|
+
const source = info.source ?? "";
|
|
33
|
+
if (info.via !== "pull" || source === "") return "only a source this server pulls can be read here";
|
|
34
|
+
const container = info.codecs?.container ?? "";
|
|
35
|
+
if (!container.split(",").includes("mpegts")) return `the source is ${container || "of unknown container"}, not a transport stream`;
|
|
36
|
+
if (input.some((arg) => HEADER_FLAGS.has(arg))) return "the source needs request headers ffmpeg sends and nixamp does not";
|
|
37
|
+
if (audio !== "") return "the source keeps its sound in a second file";
|
|
38
|
+
if (from > 0) return "a film picked up mid-way cannot be read from its start";
|
|
39
|
+
if (/^https?:\/\//i.test(source)) return null;
|
|
40
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(source)) return "only http(s) URLs and files can be read here";
|
|
41
|
+
try {
|
|
42
|
+
if (!statSync(source).isFile()) return "the source is not a file";
|
|
43
|
+
} catch {
|
|
44
|
+
return "the source file cannot be read";
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Open a plain URL or a file as a stream of bytes, pulled shut by the signal. */
|
|
50
|
+
export async function openSource(source: string, signal: AbortSignal): Promise<AsyncIterable<Uint8Array>> {
|
|
51
|
+
if (/^https?:\/\//i.test(source)) {
|
|
52
|
+
const response = await fetch(source, { signal, redirect: "follow", headers: { "user-agent": "nixamp" } });
|
|
53
|
+
if (!response.ok || !response.body) throw new Error(`the source answered ${response.status}`);
|
|
54
|
+
return response.body as unknown as AsyncIterable<Uint8Array>;
|
|
55
|
+
}
|
|
56
|
+
const stream = createReadStream(source, { highWaterMark: 256 * 1024 });
|
|
57
|
+
signal.addEventListener("abort", () => stream.destroy(), { once: true });
|
|
58
|
+
return stream;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A transport-stream source read by us, for `Channels.setThrough`. */
|
|
62
|
+
export function sourceThrough(source: string): PullThrough {
|
|
63
|
+
return { format: "mpegts", open: (signal) => openSource(source, signal) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** How many decoded bytes may wait for ffmpeg before the relay is asked to pause. */
|
|
67
|
+
const RELAY_QUEUE = 8 * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A relay's decoded bytes as an async iterable, at the pace ffmpeg takes
|
|
71
|
+
* them. The decoder is asked to wait when too much is queued, which holds
|
|
72
|
+
* the socket read, which is backpressure all the way to the sender's
|
|
73
|
+
* per-listener queue. A clean end ends the iterable; a broken stream
|
|
74
|
+
* throws, and the channel's own redial dials again.
|
|
75
|
+
*/
|
|
76
|
+
export async function* relayBytes(url: string, key: string | null, signal: AbortSignal): AsyncGenerator<Buffer> {
|
|
77
|
+
const queue: Buffer[] = [];
|
|
78
|
+
let queued = 0;
|
|
79
|
+
let wake: (() => void) | null = null;
|
|
80
|
+
let room: (() => void) | null = null;
|
|
81
|
+
let done = false;
|
|
82
|
+
let failure: Error | null = null;
|
|
83
|
+
void receiveRelay({
|
|
84
|
+
url,
|
|
85
|
+
key,
|
|
86
|
+
signal,
|
|
87
|
+
onBytes: async (bytes) => {
|
|
88
|
+
queue.push(bytes);
|
|
89
|
+
queued += bytes.length;
|
|
90
|
+
wake?.();
|
|
91
|
+
wake = null;
|
|
92
|
+
if (queued > RELAY_QUEUE) await new Promise<void>((resume) => { room = resume; });
|
|
93
|
+
},
|
|
94
|
+
}).then(
|
|
95
|
+
() => {
|
|
96
|
+
done = true;
|
|
97
|
+
wake?.();
|
|
98
|
+
},
|
|
99
|
+
(error: Error) => {
|
|
100
|
+
failure = error;
|
|
101
|
+
done = true;
|
|
102
|
+
wake?.();
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
for (;;) {
|
|
106
|
+
const next = queue.shift();
|
|
107
|
+
if (next) {
|
|
108
|
+
queued -= next.length;
|
|
109
|
+
// Captured into a local: `room` is only ever assigned inside a callback,
|
|
110
|
+
// which the compiler cannot see, so it would narrow the field to null.
|
|
111
|
+
const resume = room as (() => void) | null;
|
|
112
|
+
if (resume && queued <= RELAY_QUEUE / 2) {
|
|
113
|
+
resume();
|
|
114
|
+
room = null;
|
|
115
|
+
}
|
|
116
|
+
yield next;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (done) {
|
|
120
|
+
if (failure) throw failure;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await new Promise<void>((resume) => { wake = resume; });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A source-boundary relay from another nixamp, as a source ffmpeg reads through us. */
|
|
128
|
+
export function relayThrough(url: string, key: string | null): PullThrough {
|
|
129
|
+
return { format: "mpegts", open: async (signal) => relayBytes(url, key, signal) };
|
|
130
|
+
}
|
package/src/links.ts
CHANGED
|
@@ -177,6 +177,13 @@ export function parseResolved(json: unknown, page: string): ResolvedLink | null
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
if (media === "") return null;
|
|
180
|
+
// A link that was the media all along -- an IPTV feed at /channel/906,
|
|
181
|
+
// with no extension to say so -- is answered by yt-dlp's generic
|
|
182
|
+
// extractor with wherever the feed redirected to, which for a panel is a
|
|
183
|
+
// second host and a token minted for that one request. ffmpeg follows a
|
|
184
|
+
// redirect itself, so the link is kept as pasted and every redial gets a
|
|
185
|
+
// fresh token instead of a 403 an hour in.
|
|
186
|
+
if (record["direct"] === true && parts.length === 0 && /^https?:\/\//i.test(page)) media = page;
|
|
180
187
|
const title = typeof record["title"] === "string" && record["title"].trim() !== ""
|
|
181
188
|
? record["title"].trim().slice(0, 200)
|
|
182
189
|
: fileNameOf(page);
|
|
@@ -306,9 +313,11 @@ export async function resolveLink(
|
|
|
306
313
|
options: { cookies?: string; timeoutMs?: number; format?: string } = {},
|
|
307
314
|
): Promise<ResolvedLink | { error: string }> {
|
|
308
315
|
if (isDirectMedia(url)) return directLink(url);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
316
|
+
// Without yt-dlp, a link is taken as the media it may well be: an IPTV
|
|
317
|
+
// feed has no extension and no page behind it, and ffprobe -- asked next,
|
|
318
|
+
// before anything goes on the air -- tells a stream from a web page in a
|
|
319
|
+
// second. A page it cannot read is refused there, by name.
|
|
320
|
+
if (!ytdlp || ytdlp.length === 0) return directLink(url);
|
|
312
321
|
const [command, ...prefix] = ytdlp as [string, ...string[]];
|
|
313
322
|
return new Promise((done) => {
|
|
314
323
|
let out = "";
|
package/src/live-api.ts
CHANGED
|
@@ -628,6 +628,7 @@ export async function handleLiveApi(
|
|
|
628
628
|
json(response, error.status, { error: error.message });
|
|
629
629
|
return true;
|
|
630
630
|
}
|
|
631
|
+
console.error(`live api ${request.method} ${path}:`, error instanceof Error ? error.message : error);
|
|
631
632
|
json(response, 500, { error: "the live event service failed" });
|
|
632
633
|
return true;
|
|
633
634
|
}
|
package/src/live-events.ts
CHANGED
|
@@ -173,19 +173,22 @@ const EVENT_SCHEMA = `
|
|
|
173
173
|
ON live_events (visibility, status, starts_at, updated_at DESC);
|
|
174
174
|
CREATE INDEX IF NOT EXISTS live_events_owner
|
|
175
175
|
ON live_events (owner_id, updated_at DESC);
|
|
176
|
-
CREATE INDEX IF NOT EXISTS live_events_kind
|
|
177
|
-
ON live_events (kind, status, starts_at);
|
|
178
176
|
|
|
179
177
|
-- The repo has no migration runner, so a table that already exists is
|
|
180
178
|
-- brought forward here. Every statement is idempotent, and the CHECKs are
|
|
181
179
|
-- replaced by name rather than added twice: the originals were unnamed, so
|
|
182
180
|
-- the ones carrying the old status list are found by what they say.
|
|
181
|
+
-- The columns come before any index on them: nixamp.com had live_events
|
|
182
|
+
-- from before 0.17.0, and an index on kind ahead of ADD COLUMN kind failed
|
|
183
|
+
-- there while every fresh database (tests, CI) sailed through.
|
|
183
184
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'talk';
|
|
184
185
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS doors_open_at TIMESTAMPTZ;
|
|
185
186
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_price_cents INTEGER NOT NULL DEFAULT 0;
|
|
186
187
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_currency TEXT NOT NULL DEFAULT 'USD';
|
|
187
188
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_minutes INTEGER NOT NULL DEFAULT 1440;
|
|
188
189
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS pay_to TEXT;
|
|
190
|
+
CREATE INDEX IF NOT EXISTS live_events_kind
|
|
191
|
+
ON live_events (kind, status, starts_at);
|
|
189
192
|
|
|
190
193
|
DO $$
|
|
191
194
|
DECLARE stale record;
|
|
@@ -511,7 +514,15 @@ export class LiveEvents {
|
|
|
511
514
|
constructor(private readonly db: Queryable) {}
|
|
512
515
|
|
|
513
516
|
private async ensure(): Promise<void> {
|
|
514
|
-
|
|
517
|
+
// A failed schema run is not remembered: the next request tries again
|
|
518
|
+
// rather than answering 500 until the process restarts.
|
|
519
|
+
this.ready ??= this.db.query(EVENT_SCHEMA).then(
|
|
520
|
+
() => undefined,
|
|
521
|
+
(error: unknown) => {
|
|
522
|
+
this.ready = null;
|
|
523
|
+
throw error;
|
|
524
|
+
},
|
|
525
|
+
);
|
|
515
526
|
await this.ready;
|
|
516
527
|
}
|
|
517
528
|
|