nixamp 0.16.0 → 0.17.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/README.md +139 -0
- package/dist/audio.d.ts +70 -1
- package/dist/audio.js +84 -5
- package/dist/channels.d.ts +107 -3
- package/dist/channels.js +151 -3
- package/dist/compression/analyze.d.ts +105 -0
- package/dist/compression/analyze.js +213 -0
- package/dist/compression/blocks.d.ts +35 -0
- package/dist/compression/blocks.js +73 -0
- package/dist/compression/cli.d.ts +1 -0
- package/dist/compression/cli.js +347 -0
- package/dist/compression/codec.d.ts +66 -0
- package/dist/compression/codec.js +178 -0
- package/dist/compression/envelope.d.ts +77 -0
- package/dist/compression/envelope.js +191 -0
- package/dist/compression/jobs.d.ts +59 -0
- package/dist/compression/jobs.js +120 -0
- package/dist/compression/metrics.d.ts +62 -0
- package/dist/compression/metrics.js +64 -0
- package/dist/compression/policy.d.ts +76 -0
- package/dist/compression/policy.js +148 -0
- package/dist/compression/receiver.d.ts +51 -0
- package/dist/compression/receiver.js +92 -0
- package/dist/compression/relay.d.ts +134 -0
- package/dist/compression/relay.js +430 -0
- package/dist/compression/routes.d.ts +25 -0
- package/dist/compression/routes.js +266 -0
- package/dist/compression/service.d.ts +162 -0
- package/dist/compression/service.js +488 -0
- package/dist/compression/static.d.ts +65 -0
- package/dist/compression/static.js +248 -0
- package/dist/compression/store.d.ts +36 -0
- package/dist/compression/store.js +119 -0
- package/dist/compression/ts-transform.d.ts +44 -0
- package/dist/compression/ts-transform.js +148 -0
- package/dist/hls.d.ts +36 -3
- package/dist/hls.js +85 -11
- package/dist/layouts.d.ts +10 -0
- package/dist/layouts.js +71 -1
- package/dist/live-api.d.ts +16 -2
- package/dist/live-api.js +152 -19
- package/dist/live-events.d.ts +57 -2
- package/dist/live-events.js +243 -11
- package/dist/main.js +47 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.js +255 -0
- package/dist/oauth-api.d.ts +50 -0
- package/dist/oauth-api.js +381 -0
- package/dist/oauth-server.d.ts +174 -0
- package/dist/oauth-server.js +559 -0
- package/dist/party.d.ts +32 -0
- package/dist/party.js +196 -0
- package/dist/playlist.d.ts +11 -0
- package/dist/playlist.js +27 -6
- package/dist/server.d.ts +37 -0
- package/dist/server.js +277 -45
- package/dist/sources.d.ts +16 -8
- package/dist/sources.js +102 -0
- package/dist/tickets.d.ts +67 -0
- package/dist/tickets.js +156 -0
- package/dist/tokens.d.ts +27 -2
- package/dist/tokens.js +42 -1
- package/dist/watch-party.d.ts +127 -0
- package/dist/watch-party.js +301 -0
- package/package.json +1 -1
- package/src/audio.ts +119 -6
- package/src/channels.ts +177 -7
- package/src/compression/analyze.ts +270 -0
- package/src/compression/blocks.ts +93 -0
- package/src/compression/cli.ts +340 -0
- package/src/compression/codec.ts +202 -0
- package/src/compression/envelope.ts +237 -0
- package/src/compression/jobs.ts +157 -0
- package/src/compression/metrics.ts +109 -0
- package/src/compression/policy.ts +171 -0
- package/src/compression/receiver.ts +107 -0
- package/src/compression/relay.ts +462 -0
- package/src/compression/routes.ts +294 -0
- package/src/compression/service.ts +525 -0
- package/src/compression/static.ts +264 -0
- package/src/compression/store.ts +126 -0
- package/src/compression/ts-transform.ts +148 -0
- package/src/hls.ts +100 -10
- package/src/layouts.ts +78 -1
- package/src/live-api.ts +172 -17
- package/src/live-events.ts +269 -11
- package/src/main.ts +47 -0
- package/src/mcp.ts +282 -0
- package/src/oauth-api.ts +481 -0
- package/src/oauth-server.ts +653 -0
- package/src/party.ts +216 -0
- package/src/playlist.ts +28 -5
- package/src/server.ts +296 -44
- package/src/sources.ts +100 -0
- package/src/tickets.ts +195 -0
- package/src/tokens.ts +50 -3
- package/src/watch-party.ts +390 -0
- package/web/dist/assets/{hls-3VKVEQE3-CrILISPJ.js → hls-3VKVEQE3-eV54kXE3.js} +1 -1
- package/web/dist/assets/index-CurZFzlH.css +1 -0
- package/web/dist/assets/index-DDzutJ75.js +1 -0
- package/web/dist/assets/{mpegts-CWeN63eG.js → mpegts-Buc3Odv6.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DaMgRvlO.js → mpegts-LO6RVLD6-DcDKPB4P.js} +1 -1
- package/web/dist/index.html +59 -6
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-CQ_m5HqS.css +0 -1
- package/web/dist/assets/index-CTxPM5KS.js +0 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the server, the CLI and the API ask about compression.
|
|
3
|
+
*
|
|
4
|
+
* It owns the policies, the shared encoders, the metrics, the diagnostic
|
|
5
|
+
* jobs, the static cache and the incoming relays, and it is the only thing
|
|
6
|
+
* that touches a channel on their behalf. Every surface is a thin client
|
|
7
|
+
* of this: the routes translate HTTP into these calls and back, the CLI
|
|
8
|
+
* translates flags into the routes. There is no second copy of the rules.
|
|
9
|
+
*/
|
|
10
|
+
import { statSync } from "node:fs";
|
|
11
|
+
import { codecsOf } from "../audio.js";
|
|
12
|
+
import { GIVE_UP, REDIAL } from "../channels.js";
|
|
13
|
+
import { analyzeSample, SAMPLE_MAX_BYTES, SAMPLE_MAX_SECONDS } from "./analyze.js";
|
|
14
|
+
import { Pool } from "./codec.js";
|
|
15
|
+
import { RelayError } from "./envelope.js";
|
|
16
|
+
import { AnalysisJobs } from "./jobs.js";
|
|
17
|
+
import { ChannelMetrics } from "./metrics.js";
|
|
18
|
+
import { variantOf } from "./policy.js";
|
|
19
|
+
import { receiveRelay, RelayRefused } from "./receiver.js";
|
|
20
|
+
import { RelayEncoder } from "./relay.js";
|
|
21
|
+
import { PolicyStore } from "./store.js";
|
|
22
|
+
import { StaticCache } from "./static.js";
|
|
23
|
+
/** The pseudo-channel whose policy governs static file representations. */
|
|
24
|
+
export const STATIC_CHANNEL = "static";
|
|
25
|
+
/**
|
|
26
|
+
* A file's first bytes, up to the sample limit, analysed at the source
|
|
27
|
+
* boundary: these are the bytes as they sit on disk, before any ffmpeg.
|
|
28
|
+
* Shared by the service and the CLI, which runs it with no server at all.
|
|
29
|
+
*/
|
|
30
|
+
export async function analyzeFile(path, options = {}) {
|
|
31
|
+
const size = statSync(path).size;
|
|
32
|
+
const take = Math.min(size, SAMPLE_MAX_BYTES);
|
|
33
|
+
const fd = await import("node:fs/promises").then((fs) => fs.open(path, "r"));
|
|
34
|
+
let sample;
|
|
35
|
+
try {
|
|
36
|
+
sample = Buffer.alloc(take);
|
|
37
|
+
let at = 0;
|
|
38
|
+
while (at < take) {
|
|
39
|
+
const { bytesRead } = await fd.read(sample, at, take - at, at);
|
|
40
|
+
if (bytesRead === 0)
|
|
41
|
+
break;
|
|
42
|
+
at += bytesRead;
|
|
43
|
+
}
|
|
44
|
+
sample = sample.subarray(0, at);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await fd.close();
|
|
48
|
+
}
|
|
49
|
+
const ffprobe = options.ffprobe;
|
|
50
|
+
const codecs = ffprobe ? await codecsOf({ ffmpeg: [], ffprobe, play: null }, path) : undefined;
|
|
51
|
+
return analyzeSample(sample, {
|
|
52
|
+
boundary: "source",
|
|
53
|
+
source: { kind: "file", name: path },
|
|
54
|
+
truncated: take < size,
|
|
55
|
+
tsAware: true,
|
|
56
|
+
...(options.policy ? { policy: options.policy } : {}),
|
|
57
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
58
|
+
...(codecs && codecs.container ? { codecs } : {}),
|
|
59
|
+
...(ffprobe ? { ffprobeVersion: ffprobe.join(" ") } : {}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** An incoming relay: this server as the receiver, dialling again when it drops. */
|
|
63
|
+
class Incoming {
|
|
64
|
+
id;
|
|
65
|
+
from;
|
|
66
|
+
key;
|
|
67
|
+
name;
|
|
68
|
+
channels;
|
|
69
|
+
onEvent;
|
|
70
|
+
onGone;
|
|
71
|
+
generation = 0;
|
|
72
|
+
reconnects = 0;
|
|
73
|
+
error = null;
|
|
74
|
+
stopped = false;
|
|
75
|
+
failures = 0;
|
|
76
|
+
controller = new AbortController();
|
|
77
|
+
timer = null;
|
|
78
|
+
channel = null;
|
|
79
|
+
constructor(id, from, key, name, channels, onEvent, onGone) {
|
|
80
|
+
this.id = id;
|
|
81
|
+
this.from = from;
|
|
82
|
+
this.key = key;
|
|
83
|
+
this.name = name;
|
|
84
|
+
this.channels = channels;
|
|
85
|
+
this.onEvent = onEvent;
|
|
86
|
+
this.onGone = onGone;
|
|
87
|
+
}
|
|
88
|
+
start() {
|
|
89
|
+
void this.dial();
|
|
90
|
+
}
|
|
91
|
+
async dial() {
|
|
92
|
+
if (this.stopped)
|
|
93
|
+
return;
|
|
94
|
+
this.controller = new AbortController();
|
|
95
|
+
let received = 0;
|
|
96
|
+
try {
|
|
97
|
+
await receiveRelay({
|
|
98
|
+
url: this.from,
|
|
99
|
+
key: this.key,
|
|
100
|
+
signal: this.controller.signal,
|
|
101
|
+
onStart: ({ kind }) => {
|
|
102
|
+
if (this.channel === null) {
|
|
103
|
+
const opened = this.channels.relayIn(this.id, this.name, kind === "video" ? "video" : "audio", this.from);
|
|
104
|
+
if (!opened)
|
|
105
|
+
throw new RelayRefused(409, `channel "${this.id}" is already on`);
|
|
106
|
+
this.channel = opened;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// A new generation upstream: new opening boxes, so everybody
|
|
110
|
+
// watching here starts over too.
|
|
111
|
+
this.channel.rollover();
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
onHeader: (header) => {
|
|
115
|
+
this.generation = header.generation;
|
|
116
|
+
},
|
|
117
|
+
onBytes: (bytes) => {
|
|
118
|
+
received += bytes.length;
|
|
119
|
+
this.channel?.receive(bytes);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
// A clean end is the upstream's source starting over, or going off.
|
|
123
|
+
// Either way: dial again, and let the new generation say which.
|
|
124
|
+
this.error = null;
|
|
125
|
+
this.failures = 0;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (this.stopped)
|
|
129
|
+
return;
|
|
130
|
+
this.error = error instanceof RelayError ? `${error.code}: ${error.message}` : error.message;
|
|
131
|
+
this.onEvent(` relay "${this.id}" from ${this.from}: ${this.error}`);
|
|
132
|
+
this.failures = received > 0 ? 0 : this.failures + 1;
|
|
133
|
+
if (error instanceof RelayRefused && error.status === 409 && this.channel === null) {
|
|
134
|
+
this.stop();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (this.failures >= GIVE_UP) {
|
|
138
|
+
this.onEvent(` relay "${this.id}" gave up: ${GIVE_UP} dials without a byte`);
|
|
139
|
+
this.stop();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (this.stopped || !this.channels.has(this.id)) {
|
|
144
|
+
this.stop();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.reconnects += 1;
|
|
148
|
+
this.timer = setTimeout(() => {
|
|
149
|
+
this.timer = null;
|
|
150
|
+
void this.dial();
|
|
151
|
+
}, REDIAL);
|
|
152
|
+
this.timer.unref?.();
|
|
153
|
+
}
|
|
154
|
+
stop() {
|
|
155
|
+
if (this.stopped)
|
|
156
|
+
return;
|
|
157
|
+
this.stopped = true;
|
|
158
|
+
if (this.timer)
|
|
159
|
+
clearTimeout(this.timer);
|
|
160
|
+
this.timer = null;
|
|
161
|
+
this.controller.abort();
|
|
162
|
+
this.channel?.close();
|
|
163
|
+
this.channel = null;
|
|
164
|
+
this.onGone(this.id);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export class CompressionService {
|
|
168
|
+
options;
|
|
169
|
+
store;
|
|
170
|
+
jobs;
|
|
171
|
+
pool;
|
|
172
|
+
statics;
|
|
173
|
+
running = new Map();
|
|
174
|
+
metrics = new Map();
|
|
175
|
+
incoming = new Map();
|
|
176
|
+
generation = Math.floor(Date.now() / 1000) % 0x7fff_ffff;
|
|
177
|
+
constructor(options) {
|
|
178
|
+
this.options = options;
|
|
179
|
+
this.store = new PolicyStore(options.stateDir, options.port);
|
|
180
|
+
this.jobs = new AnalysisJobs({ concurrency: 1 });
|
|
181
|
+
this.pool = options.pool ?? new Pool({ concurrency: 4, maxQueued: 256, timeoutMs: 2000 });
|
|
182
|
+
this.statics = options.cacheDir
|
|
183
|
+
? new StaticCache(options.cacheDir, { pool: this.pool, ...(options.maxCacheBytes !== undefined ? { maxBytes: options.maxCacheBytes } : {}) })
|
|
184
|
+
: null;
|
|
185
|
+
}
|
|
186
|
+
get channels() {
|
|
187
|
+
return this.options.channels;
|
|
188
|
+
}
|
|
189
|
+
/** What applies to a channel right now, and why that is not what is configured, if it is not. */
|
|
190
|
+
effective(id) {
|
|
191
|
+
const configured = this.store.get(id);
|
|
192
|
+
const global = this.store.global;
|
|
193
|
+
const lossless = { ...configured.losslessCompression };
|
|
194
|
+
let reason = null;
|
|
195
|
+
if (!global.enabled && lossless.mode !== "off") {
|
|
196
|
+
lossless.mode = "off";
|
|
197
|
+
reason = "compression is off for the whole server";
|
|
198
|
+
}
|
|
199
|
+
else if (lossless.mode !== "off" && lossless.boundary === "source") {
|
|
200
|
+
lossless.mode = "off";
|
|
201
|
+
reason = "original source bytes are unavailable: this server's sources are read by ffmpeg, and only the channel boundary can be relayed";
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
losslessCompression: lossless,
|
|
205
|
+
hlsPackaging: this.store.has(id) ? configured.hlsPackaging : global.hlsPackaging,
|
|
206
|
+
qualityProfile: configured.qualityProfile,
|
|
207
|
+
reason,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/** For the HLS packager: how to wrap this channel. */
|
|
211
|
+
packagingOf(id) {
|
|
212
|
+
return this.effective(id).hlsPackaging;
|
|
213
|
+
}
|
|
214
|
+
status(id) {
|
|
215
|
+
const live = this.channels.has(id);
|
|
216
|
+
const running = this.running.get(id);
|
|
217
|
+
const inbound = this.incoming.get(id);
|
|
218
|
+
return {
|
|
219
|
+
channel: id,
|
|
220
|
+
live,
|
|
221
|
+
configured: this.store.get(id),
|
|
222
|
+
effective: this.effective(id),
|
|
223
|
+
global: this.store.global,
|
|
224
|
+
metrics: this.metrics.get(id)?.snapshot() ?? null,
|
|
225
|
+
relay: running ? { sessions: running.encoder.sessionCount, generation: running.encoder.generation } : null,
|
|
226
|
+
incoming: inbound ? { from: inbound.from, generation: inbound.generation, reconnects: inbound.reconnects, error: inbound.error } : null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** The whole server at a glance. */
|
|
230
|
+
overview() {
|
|
231
|
+
const ids = new Set([...this.channels.list().map((c) => c.id), ...Object.keys(this.store.list()), ...this.running.keys(), ...this.incoming.keys()]);
|
|
232
|
+
return {
|
|
233
|
+
global: this.store.global,
|
|
234
|
+
pool: this.pool.stats,
|
|
235
|
+
jobs: this.jobs.counts,
|
|
236
|
+
channels: [...ids].sort().map((id) => this.status(id)),
|
|
237
|
+
cache: this.statics ? { bytes: this.statics.totalBytes, entries: this.statics.entries().length } : null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** Change a channel's policy. A live relay under a different variant is ended; receivers reconnect. */
|
|
241
|
+
set(id, change, expectVersion) {
|
|
242
|
+
const result = this.store.set(id, change, expectVersion);
|
|
243
|
+
if (result.ok)
|
|
244
|
+
this.applyPolicy(id);
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
setGlobal(change) {
|
|
248
|
+
const settings = this.store.setGlobal(change);
|
|
249
|
+
for (const id of [...this.running.keys()])
|
|
250
|
+
this.applyPolicy(id);
|
|
251
|
+
return settings;
|
|
252
|
+
}
|
|
253
|
+
/** The kill switch, and its opposite. */
|
|
254
|
+
applyPolicy(id) {
|
|
255
|
+
const running = this.running.get(id);
|
|
256
|
+
if (!running)
|
|
257
|
+
return;
|
|
258
|
+
const effective = this.effective(id).losslessCompression;
|
|
259
|
+
if (effective.mode === "off" || variantOf(effective) !== running.variant) {
|
|
260
|
+
// Not a clean end: the receiver sees the stream stop without its
|
|
261
|
+
// marker, reports a disconnect, and dials again under the new policy.
|
|
262
|
+
running.encoder.end();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A receiver asking for a channel. Everything that can go wrong is
|
|
267
|
+
* answered before a byte of stream: no such channel, compression off,
|
|
268
|
+
* a boundary this server cannot provide, a receiver that cannot decode
|
|
269
|
+
* anything worth sending.
|
|
270
|
+
*/
|
|
271
|
+
relay(id, listener, offered) {
|
|
272
|
+
if (!this.channels.has(id))
|
|
273
|
+
return { ok: false, status: 404, code: "NO_SUCH_CHANNEL", error: "nothing is playing on that channel" };
|
|
274
|
+
const configured = this.store.get(id).losslessCompression;
|
|
275
|
+
const effective = this.effective(id);
|
|
276
|
+
const policy = effective.losslessCompression;
|
|
277
|
+
if (policy.mode === "off") {
|
|
278
|
+
if (configured.mode !== "off" && configured.boundary === "source") {
|
|
279
|
+
return { ok: false, status: 409, code: "SOURCE_BOUNDARY_UNAVAILABLE", error: effective.reason ?? "original source bytes are unavailable" };
|
|
280
|
+
}
|
|
281
|
+
return { ok: false, status: 409, code: "COMPRESSION_OFF", error: effective.reason ?? "compression is off for this channel; the ordinary channel URL is unaffected" };
|
|
282
|
+
}
|
|
283
|
+
const codecs = ["stored"];
|
|
284
|
+
if (offered.has("zstd"))
|
|
285
|
+
codecs.push("zstd");
|
|
286
|
+
if (offered.has("ts-zstd") && policy.tsAware)
|
|
287
|
+
codecs.push("ts-zstd");
|
|
288
|
+
if (codecs.length === 1) {
|
|
289
|
+
return { ok: false, status: 406, code: "RECEIVER_UNSUPPORTED", error: "receiver does not support this format: it must decode zstd; the ordinary channel URL is unaffected" };
|
|
290
|
+
}
|
|
291
|
+
// A receiver that cannot undo the transform gets a variant without it.
|
|
292
|
+
const variantPolicy = { ...policy, tsAware: policy.tsAware && codecs.includes("ts-zstd") };
|
|
293
|
+
const variant = variantOf(variantPolicy);
|
|
294
|
+
let running = this.running.get(id);
|
|
295
|
+
if (running && running.variant !== variant) {
|
|
296
|
+
// One compressor per channel. A second variant would be a second
|
|
297
|
+
// compressor; the first receiver's policy stands until it leaves.
|
|
298
|
+
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
|
+
}
|
|
300
|
+
if (!running) {
|
|
301
|
+
const started = this.startEncoder(id, variantPolicy, variant);
|
|
302
|
+
if (!started)
|
|
303
|
+
return { ok: false, status: 503, code: "CHANNEL_GONE", error: "the channel ended before the relay could start" };
|
|
304
|
+
running = started;
|
|
305
|
+
}
|
|
306
|
+
// The opening bytes and the join point, in the same tick.
|
|
307
|
+
const preface = this.channels.opening(id);
|
|
308
|
+
const session = running.encoder.join(listener, preface);
|
|
309
|
+
return { ok: true, session, codecs, generation: running.encoder.generation, kind: this.channels.kindOf(id) ?? "" };
|
|
310
|
+
}
|
|
311
|
+
startEncoder(id, policy, variant) {
|
|
312
|
+
this.generation = (this.generation + 1) % 0xffff_ffff;
|
|
313
|
+
const metrics = this.metrics.get(id) ?? new ChannelMetrics();
|
|
314
|
+
this.metrics.set(id, metrics);
|
|
315
|
+
let record = null;
|
|
316
|
+
const encoder = new RelayEncoder({
|
|
317
|
+
generation: this.generation,
|
|
318
|
+
policy,
|
|
319
|
+
pool: this.pool,
|
|
320
|
+
metrics,
|
|
321
|
+
boundary: "channel",
|
|
322
|
+
onAbort: () => {
|
|
323
|
+
if (record && this.running.get(id) === record) {
|
|
324
|
+
this.running.delete(id);
|
|
325
|
+
record.detach();
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
onIdle: () => {
|
|
329
|
+
// Nobody receiving: no compressor running. The next receiver
|
|
330
|
+
// starts a new generation.
|
|
331
|
+
if (record && this.running.get(id) === record) {
|
|
332
|
+
this.running.delete(id);
|
|
333
|
+
record.detach();
|
|
334
|
+
encoder.end();
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const detach = this.channels.listen(id, {
|
|
339
|
+
write: (chunk) => encoder.write(chunk),
|
|
340
|
+
end: () => encoder.end(),
|
|
341
|
+
pending: () => metrics.queueBytes,
|
|
342
|
+
});
|
|
343
|
+
if (detach === null)
|
|
344
|
+
return null;
|
|
345
|
+
encoder.attach();
|
|
346
|
+
record = { encoder, detach, variant };
|
|
347
|
+
this.running.set(id, record);
|
|
348
|
+
return record;
|
|
349
|
+
}
|
|
350
|
+
/** Start listening to another nixamp's channel as one of ours. */
|
|
351
|
+
pull(id, from, key, name) {
|
|
352
|
+
if (!/^https?:\/\//i.test(from))
|
|
353
|
+
return { ok: false, status: 400, error: "the relay address must be http or https" };
|
|
354
|
+
if (this.channels.has(id) || this.incoming.has(id))
|
|
355
|
+
return { ok: false, status: 409, error: `channel "${id}" is already on` };
|
|
356
|
+
const inbound = new Incoming(id, from, key, name, this.channels, this.options.onEvent ?? (() => undefined), (gone) => this.incoming.delete(gone));
|
|
357
|
+
this.incoming.set(id, inbound);
|
|
358
|
+
inbound.start();
|
|
359
|
+
return { ok: true };
|
|
360
|
+
}
|
|
361
|
+
/** Stop an incoming relay, and the channel it feeds. */
|
|
362
|
+
stopPull(id) {
|
|
363
|
+
const inbound = this.incoming.get(id);
|
|
364
|
+
if (!inbound)
|
|
365
|
+
return false;
|
|
366
|
+
inbound.stop();
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Analyse a live channel: up to `seconds` of it, or the byte limit,
|
|
371
|
+
* whichever comes first, as a job. The listener is one more on the
|
|
372
|
+
* channel; the channel is not touched.
|
|
373
|
+
*/
|
|
374
|
+
analyzeChannel(id, seconds, owner) {
|
|
375
|
+
if (!this.channels.has(id))
|
|
376
|
+
return { error: "nothing is playing on that channel", status: 404 };
|
|
377
|
+
const window = Math.min(SAMPLE_MAX_SECONDS, Math.max(1, Math.floor(seconds)));
|
|
378
|
+
const started = this.jobs.start(`channel:${id}:${window}`, `channel ${id}`, owner, (signal, progress) => this.sampleChannel(id, window, signal, progress));
|
|
379
|
+
if (started === null)
|
|
380
|
+
return { error: "too many analyses are queued; try again later", status: 429 };
|
|
381
|
+
return started;
|
|
382
|
+
}
|
|
383
|
+
sampleChannel(id, seconds, signal, progress) {
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
const pieces = [];
|
|
386
|
+
let bytes = 0;
|
|
387
|
+
const began = Date.now();
|
|
388
|
+
let detach = null;
|
|
389
|
+
let finished = false;
|
|
390
|
+
const finish = (truncated) => {
|
|
391
|
+
if (finished)
|
|
392
|
+
return;
|
|
393
|
+
finished = true;
|
|
394
|
+
clearTimeout(timer);
|
|
395
|
+
detach?.();
|
|
396
|
+
const info = this.channels.list().find((c) => c.id === id);
|
|
397
|
+
const sample = Buffer.concat(pieces, bytes);
|
|
398
|
+
analyzeSample(sample, {
|
|
399
|
+
boundary: "channel",
|
|
400
|
+
source: { kind: "channel", name: id },
|
|
401
|
+
sampleMs: Date.now() - began,
|
|
402
|
+
truncated,
|
|
403
|
+
signal,
|
|
404
|
+
policy: this.store.get(id).losslessCompression,
|
|
405
|
+
tsAware: true,
|
|
406
|
+
...(info?.codecs ? { codecs: info.codecs } : {}),
|
|
407
|
+
}).then(resolve, reject);
|
|
408
|
+
};
|
|
409
|
+
const timer = setTimeout(() => finish(false), seconds * 1000);
|
|
410
|
+
timer.unref?.();
|
|
411
|
+
signal.addEventListener("abort", () => {
|
|
412
|
+
if (finished)
|
|
413
|
+
return;
|
|
414
|
+
finished = true;
|
|
415
|
+
clearTimeout(timer);
|
|
416
|
+
detach?.();
|
|
417
|
+
reject(new Error("cancelled"));
|
|
418
|
+
}, { once: true });
|
|
419
|
+
detach = this.channels.listen(id, {
|
|
420
|
+
write: (chunk) => {
|
|
421
|
+
if (finished)
|
|
422
|
+
return true;
|
|
423
|
+
pieces.push(Buffer.from(chunk));
|
|
424
|
+
bytes += chunk.length;
|
|
425
|
+
progress(bytes, Date.now() - began);
|
|
426
|
+
if (bytes >= SAMPLE_MAX_BYTES)
|
|
427
|
+
finish(true);
|
|
428
|
+
return true;
|
|
429
|
+
},
|
|
430
|
+
end: () => finish(false),
|
|
431
|
+
});
|
|
432
|
+
if (detach === null) {
|
|
433
|
+
finished = true;
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
reject(new Error("nothing is playing on that channel"));
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/** Analyse a file on this machine: its first bytes, up to the sample limit. */
|
|
440
|
+
analyzeFile(path, signal) {
|
|
441
|
+
return analyzeFile(path, {
|
|
442
|
+
policy: this.store.get(STATIC_CHANNEL).losslessCompression,
|
|
443
|
+
...(this.options.ffprobe ? { ffprobe: this.options.ffprobe } : {}),
|
|
444
|
+
...(signal ? { signal } : {}),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The static representation of a file. Ready when it is on disk and
|
|
449
|
+
* still describes the file; building when this call started it (or
|
|
450
|
+
* found it being built); failed when the last build said why; off when
|
|
451
|
+
* this server keeps no cache or the static policy is off. Never a wait:
|
|
452
|
+
* a film takes minutes to read, and a request is not held for that.
|
|
453
|
+
*/
|
|
454
|
+
representation(file) {
|
|
455
|
+
if (!this.statics)
|
|
456
|
+
return { state: "off" };
|
|
457
|
+
const policy = this.effective(STATIC_CHANNEL).losslessCompression;
|
|
458
|
+
if (policy.mode === "off")
|
|
459
|
+
return { state: "off" };
|
|
460
|
+
const variant = variantOf(policy);
|
|
461
|
+
const found = this.statics.lookup(file, variant);
|
|
462
|
+
if (found) {
|
|
463
|
+
this.failed.delete(file);
|
|
464
|
+
return { state: "ready", path: found.path, entry: found.entry };
|
|
465
|
+
}
|
|
466
|
+
const failure = this.failed.get(file);
|
|
467
|
+
if (failure !== undefined) {
|
|
468
|
+
this.failed.delete(file);
|
|
469
|
+
return { state: "failed", reason: failure };
|
|
470
|
+
}
|
|
471
|
+
void this.statics.prepare(file, policy, variant).then((result) => {
|
|
472
|
+
if (!result.ok)
|
|
473
|
+
this.failed.set(file, result.reason);
|
|
474
|
+
});
|
|
475
|
+
return { state: "building" };
|
|
476
|
+
}
|
|
477
|
+
failed = new Map();
|
|
478
|
+
stopAll() {
|
|
479
|
+
for (const [id, running] of [...this.running]) {
|
|
480
|
+
this.running.delete(id);
|
|
481
|
+
running.detach();
|
|
482
|
+
running.encoder.end();
|
|
483
|
+
}
|
|
484
|
+
for (const inbound of [...this.incoming.values()])
|
|
485
|
+
inbound.stop();
|
|
486
|
+
this.jobs.stopAll();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Pool } from "./codec.ts";
|
|
2
|
+
import { type LosslessPolicy } from "./policy.ts";
|
|
3
|
+
export declare const REPRESENTATION_SUFFIX = ".nxs";
|
|
4
|
+
export interface StaticEntry {
|
|
5
|
+
file: string;
|
|
6
|
+
size: number;
|
|
7
|
+
mtimeMs: number;
|
|
8
|
+
/** Of the original file, whole. */
|
|
9
|
+
sha256: string;
|
|
10
|
+
/** The policy variant it was built under. Another variant is another entry. */
|
|
11
|
+
variant: string;
|
|
12
|
+
/** How big the representation is, headers included. */
|
|
13
|
+
bytes: number;
|
|
14
|
+
blocks: number;
|
|
15
|
+
compressedBlocks: number;
|
|
16
|
+
builtAt: number;
|
|
17
|
+
lastUsedAt: number;
|
|
18
|
+
}
|
|
19
|
+
export interface StaticCacheOptions {
|
|
20
|
+
/** Total bytes the cache may hold. */
|
|
21
|
+
maxBytes?: number;
|
|
22
|
+
pool?: Pool;
|
|
23
|
+
}
|
|
24
|
+
export type Prepared = {
|
|
25
|
+
ok: true;
|
|
26
|
+
entry: StaticEntry;
|
|
27
|
+
path: string;
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
reason: string;
|
|
31
|
+
};
|
|
32
|
+
export declare class StaticCache {
|
|
33
|
+
readonly dir: string;
|
|
34
|
+
private readonly building;
|
|
35
|
+
private readonly pool;
|
|
36
|
+
private readonly maxBytes;
|
|
37
|
+
constructor(dir: string, options?: StaticCacheOptions);
|
|
38
|
+
/** The cache file name for a file under a variant: from its path, never its content, so it can be found before the content is read. */
|
|
39
|
+
private nameFor;
|
|
40
|
+
/**
|
|
41
|
+
* The representation of `file`, if one is there and still describes the
|
|
42
|
+
* file on disk. Null otherwise -- and a stale one is removed, not kept
|
|
43
|
+
* for a later lookup to be fooled by.
|
|
44
|
+
*/
|
|
45
|
+
lookup(file: string, variant: string): {
|
|
46
|
+
entry: StaticEntry;
|
|
47
|
+
path: string;
|
|
48
|
+
} | null;
|
|
49
|
+
/**
|
|
50
|
+
* Build the representation, or return the one being built. Two requests
|
|
51
|
+
* for the same file do one read.
|
|
52
|
+
*/
|
|
53
|
+
prepare(file: string, policy: LosslessPolicy, variant: string): Promise<Prepared>;
|
|
54
|
+
private build;
|
|
55
|
+
private encodeBlock;
|
|
56
|
+
/** Every entry on disk, oldest use first. */
|
|
57
|
+
entries(): StaticEntry[];
|
|
58
|
+
get totalBytes(): number;
|
|
59
|
+
/** Drop the least recently served until `incoming` more bytes fit. */
|
|
60
|
+
private makeRoom;
|
|
61
|
+
remove(file: string, variant: string): void;
|
|
62
|
+
private drop;
|
|
63
|
+
/** What the envelope adds to a file of `size` bytes at worst: every block stored. */
|
|
64
|
+
static worstCaseBytes(size: number, blockBytes: number): number;
|
|
65
|
+
}
|