@torrent-tv/proxy 2.102.2 → 2.102.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/routes/api/sources/post.js +2 -1
- package/routes/encode-input/get.js +11 -1
- package/routes/stream/get.js +10 -1
- package/server.js +28 -6
- package/services/encode/EncodeInputs.js +25 -2
- package/services/encode/EncodeRuns.js +50 -0
- package/services/media/ContainerOrchestrator.js +2 -3
- package/services/server/SegmentServing.js +11 -1
- package/services/server/ViewerRequests.js +14 -5
- package/services/server/wire-outputs.js +3 -0
- package/services/torrent/piece-timing.js +2 -0
- package/services/torrent/torrent-pool.js +90 -12
- package/services/torrent/worker/client.js +11 -5
- package/services/torrent/worker/pool-adapter.js +5 -2
- package/services/torrent/worker/protocol.js +2 -0
- package/services/torrent/worker/worker.js +25 -1
- package/services/viewer/DownloadMaps.js +20 -3
- package/utils/wait-trace.js +38 -4
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@ function getPayload(body) {
|
|
|
23
23
|
return {};
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export async function handleApiSourcesPost(req, reply, { sourceRegistry, viewers }) {
|
|
26
|
+
export async function handleApiSourcesPost(req, reply, { sourceRegistry, viewers, associateWaitTrace }) {
|
|
27
27
|
const payload = getPayload(req.body);
|
|
28
28
|
const sourceType = typeof payload.sourceType === "string" ? payload.sourceType : "";
|
|
29
29
|
const source = typeof payload.source === "string" ? payload.source : "";
|
|
@@ -34,6 +34,7 @@ export async function handleApiSourcesPost(req, reply, { sourceRegistry, viewers
|
|
|
34
34
|
const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
|
|
35
35
|
const viewer = consumerId ? viewers?.present(consumerId) : null;
|
|
36
36
|
const sourceKey = await sourceRegistry.upsert(sourceType, source);
|
|
37
|
+
associateWaitTrace?.(req, sourceKey);
|
|
37
38
|
if (viewer && viewers.get(consumerId) === viewer) viewers.selectsSource(consumerId, sourceKey);
|
|
38
39
|
return reply.send({ sourceKey, playbackMapVersion: 1 });
|
|
39
40
|
}
|
|
@@ -2,7 +2,7 @@ import { parseRange } from "../../utils/parse-range.js";
|
|
|
2
2
|
import { Readable } from "node:stream";
|
|
3
3
|
|
|
4
4
|
/** FFmpeg reads only original byte ranges already owned by its live run. */
|
|
5
|
-
export async function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }) {
|
|
5
|
+
export async function handleEncodeInputGet(req, reply, { inputOf, refused = () => {}, noteFirstInputBytes = () => {} }) {
|
|
6
6
|
const address = req.raw?.socket?.remoteAddress;
|
|
7
7
|
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(address)) return reply.code(403).send();
|
|
8
8
|
const token = Number(req.params.token), fileIndex = Number(req.params.fileIndex);
|
|
@@ -53,6 +53,16 @@ export async function handleEncodeInputGet(req, reply, { inputOf, refused = () =
|
|
|
53
53
|
return reply;
|
|
54
54
|
}
|
|
55
55
|
const body = held.stream ?? (held.chunks ? Readable.from(held.chunks, { objectMode: false }) : held.bytes);
|
|
56
|
+
if (body && typeof body.once === "function") {
|
|
57
|
+
let noted = false;
|
|
58
|
+
body.once("data", chunk => {
|
|
59
|
+
if (noted) return;
|
|
60
|
+
noted = true;
|
|
61
|
+
noteFirstInputBytes(token, fileIndex, chunk?.length ?? 0);
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
noteFirstInputBytes(token, fileIndex, body?.length ?? 0);
|
|
65
|
+
}
|
|
56
66
|
// What was handed to the system for this answer before its connection
|
|
57
67
|
// closed, and whether all of it was: an answer runs to the end of its
|
|
58
68
|
// admitted range and FFmpeg closes the connection once it has what it wants,
|
package/routes/stream/get.js
CHANGED
|
@@ -229,7 +229,8 @@ function serveWholeFile(req, reply, file, read) {
|
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null, noteInputWaiting = null
|
|
232
|
+
export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null, noteInputWaiting = null,
|
|
233
|
+
noteFirstInputBytes = null }) {
|
|
233
234
|
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
234
235
|
const fileIndex = Number(fileIndexRaw);
|
|
235
236
|
const { sourceType, source, sourceKey } = getSourceParams(req.query, sourceRegistry);
|
|
@@ -386,6 +387,9 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
386
387
|
reply.raw.write(fragment.bytes, (error) => (error ? reject(error) : resolve()));
|
|
387
388
|
});
|
|
388
389
|
sent += fragment.bytes.length;
|
|
390
|
+
if (sent === fragment.bytes.length && noteFirstInputBytes && Number.isSafeInteger(runToken)) {
|
|
391
|
+
noteFirstInputBytes(runToken, fileIndex, fragment.bytes.length);
|
|
392
|
+
}
|
|
389
393
|
// Say so, if this read belongs to a transcode session. It is the only
|
|
390
394
|
// proof that a session which has produced nothing yet is nevertheless
|
|
391
395
|
// being fed: the encoder's own progress cannot move until its first
|
|
@@ -412,10 +416,15 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
412
416
|
if (continueFrom) {
|
|
413
417
|
fragments.cancel();
|
|
414
418
|
const fromTorrent = sent;
|
|
419
|
+
let firstWholeInputNoted = false;
|
|
415
420
|
sent += await writeFromWholeFile(reply.raw, continueFrom, start + sent, end, (length) => {
|
|
416
421
|
if (noteInputBytes) {
|
|
417
422
|
noteInputBytes(sessionId, length);
|
|
418
423
|
}
|
|
424
|
+
if (!firstWholeInputNoted && noteFirstInputBytes && Number.isSafeInteger(runToken)) {
|
|
425
|
+
firstWholeInputNoted = true;
|
|
426
|
+
noteFirstInputBytes(runToken, fileIndex, length);
|
|
427
|
+
}
|
|
419
428
|
});
|
|
420
429
|
logger.info(
|
|
421
430
|
`stream: read of "${file.name}" bytes ${start}-${end} continued from the whole file: ` +
|
package/server.js
CHANGED
|
@@ -178,7 +178,8 @@ export async function startProxyServer({
|
|
|
178
178
|
await app.register(fastifyCors, {
|
|
179
179
|
origin: true,
|
|
180
180
|
methods: ["GET", "POST", "OPTIONS"],
|
|
181
|
-
allowedHeaders: ["Content-Type", "Range", "X-Ttv-Wait-Trace", "X-Ttv-Wait-Attempt", "X-Ttv-Wait-Operation"
|
|
181
|
+
allowedHeaders: ["Content-Type", "Range", "X-Ttv-Wait-Trace", "X-Ttv-Wait-Attempt", "X-Ttv-Wait-Operation",
|
|
182
|
+
"X-Ttv-Wait-Process"]
|
|
182
183
|
});
|
|
183
184
|
|
|
184
185
|
// Allow browser requests from an HTTPS page to this private-network proxy
|
|
@@ -223,7 +224,15 @@ export async function startProxyServer({
|
|
|
223
224
|
// 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
|
|
224
225
|
// adapter keeps TorrentPool's interface, so nothing downstream changed.
|
|
225
226
|
const torrentPool = new WorkerTorrentPool({
|
|
226
|
-
|
|
227
|
+
isWaitTraced: sourceKey => waitTrace.has(sourceKey),
|
|
228
|
+
onWaitTrace: event => waitTrace.process(event.sourceKey, event.process, event.phase, event),
|
|
229
|
+
onObserved: fields => {
|
|
230
|
+
waitTrace.source(fields.sourceKey, "process", { process: "P1", phase: "begin", at: fields.startedAt,
|
|
231
|
+
outcome: fields.outcome });
|
|
232
|
+
waitTrace.source(fields.sourceKey, "process", { process: "P1", phase: "end", at: fields.endedAt,
|
|
233
|
+
startedAt: fields.startedAt, endedAt: fields.endedAt, durationMs: fields.endedAt - fields.startedAt,
|
|
234
|
+
outcome: fields.outcome });
|
|
235
|
+
},
|
|
227
236
|
memoryBytes,
|
|
228
237
|
stateDir,
|
|
229
238
|
heapSnapshots,
|
|
@@ -254,8 +263,8 @@ export async function startProxyServer({
|
|
|
254
263
|
// only once the worker thread is up and a piece has verified, which is long
|
|
255
264
|
// after the declarations below have run.
|
|
256
265
|
onPiecesArrived: ({ sourceKey, fileIndexes, arrived }) => {
|
|
257
|
-
waitTrace.source(sourceKey, "
|
|
258
|
-
arrivedCount: arrived?.length });
|
|
266
|
+
waitTrace.source(sourceKey, "process", { process: "W9", phase: "end", fileIndexes,
|
|
267
|
+
arrived: arrived?.slice?.(0, 32), arrivedCount: arrived?.length });
|
|
259
268
|
for (const fileIndex of fileIndexes) {
|
|
260
269
|
mediaReads.bytesChanged(sourceKey, fileIndex);
|
|
261
270
|
probeReads.bytesChanged(sourceKey, fileIndex);
|
|
@@ -654,6 +663,7 @@ export async function startProxyServer({
|
|
|
654
663
|
// already from the projection; this packs them with the native ones.
|
|
655
664
|
pack: (zones) => coalescedZones(zones),
|
|
656
665
|
log: (line) => logger.info(line),
|
|
666
|
+
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
|
|
657
667
|
resolvePlayback: async (map) => {
|
|
658
668
|
const fileKey = `${map.sourceKey}:${map.fileIndex}`;
|
|
659
669
|
if (map.zones.length === 0) {
|
|
@@ -877,7 +887,16 @@ export async function startProxyServer({
|
|
|
877
887
|
} }),
|
|
878
888
|
label: String(file.name ?? ""),
|
|
879
889
|
fileOffset: file.offset,
|
|
880
|
-
onObserved: fields =>
|
|
890
|
+
onObserved: fields => {
|
|
891
|
+
const process = fields.statement === "keyframes" ? "P5"
|
|
892
|
+
: String(fields.statement).startsWith("ffprobe:") ? "X1" : "P2";
|
|
893
|
+
const details = { process, fileIndex, requestId, statement: fields.statement, cached: fields.cached,
|
|
894
|
+
result: fields.result, reason: fields.reason, ranges: fields.ranges,
|
|
895
|
+
queuedMs: fields.queuedMs, readMs: fields.readMs };
|
|
896
|
+
waitTrace.source(sourceKey, "process", { ...details, phase: "begin", at: fields.askedAt });
|
|
897
|
+
waitTrace.source(sourceKey, "process", { ...details, phase: "end", at: fields.endedAt,
|
|
898
|
+
startedAt: fields.askedAt, endedAt: fields.endedAt });
|
|
899
|
+
},
|
|
881
900
|
onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
|
|
882
901
|
onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
|
|
883
902
|
onReadResult: async (statement, result, revision) => {
|
|
@@ -1474,7 +1493,8 @@ export async function startProxyServer({
|
|
|
1474
1493
|
app.post("/api/sources", async (req, reply) => {
|
|
1475
1494
|
// A cold start begins with the first source opened (torrent-tv/meta#177).
|
|
1476
1495
|
startupProfile.begin();
|
|
1477
|
-
return handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers
|
|
1496
|
+
return handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers,
|
|
1497
|
+
associateWaitTrace: (request, sourceKey) => waitTrace.associate(request, sourceKey) });
|
|
1478
1498
|
});
|
|
1479
1499
|
app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
|
|
1480
1500
|
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests: outputParts.viewerRequests })
|
|
@@ -1563,6 +1583,7 @@ export async function startProxyServer({
|
|
|
1563
1583
|
);
|
|
1564
1584
|
app.get("/encode-input/:token/:fileIndex", (req, reply) =>
|
|
1565
1585
|
handleEncodeInputGet(req, reply, { inputOf: token => outputParts.encodeRuns.originalInputOf(token),
|
|
1586
|
+
noteFirstInputBytes: (token, fileIndex, bytes) => outputParts.encodeRuns.noteFirstInputBytes(token, fileIndex, bytes),
|
|
1566
1587
|
refused: ({ fileIndex, start, runTag }) => logger.warn(`encode input: run ${runTag ?? "?"} asked for byte ${start} of file ${fileIndex}, which it was not given`) })
|
|
1567
1588
|
);
|
|
1568
1589
|
app.get("/stream", async (req, reply) =>
|
|
@@ -1573,6 +1594,7 @@ export async function startProxyServer({
|
|
|
1573
1594
|
// fed. The route knows only files; the session id rides on the URL the
|
|
1574
1595
|
// session itself built.
|
|
1575
1596
|
noteInputBytes: (sessionId, bytes) => outputParts.encodeRuns.noteInputBytes(sessionId, bytes),
|
|
1597
|
+
noteFirstInputBytes: (token, fileIndex, bytes) => outputParts.encodeRuns.noteFirstInputBytes(token, fileIndex, bytes),
|
|
1576
1598
|
// When the encoder reading this waits for its input, so its speed is
|
|
1577
1599
|
// measured over its own work and not over the swarm's.
|
|
1578
1600
|
noteInputWaiting: (runToken, waiting) => outputParts.encodeRuns.noteInputWaiting(runToken, waiting)
|
|
@@ -65,10 +65,13 @@ export class EncodeInputs {
|
|
|
65
65
|
* @type {(output: object, from: number, to: number, input: object) => void}
|
|
66
66
|
*/
|
|
67
67
|
#stretchCopied;
|
|
68
|
+
#observeWait;
|
|
68
69
|
|
|
69
70
|
constructor({ resolve, readRanges, openWholeFile = null, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true,
|
|
70
|
-
rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {}
|
|
71
|
+
rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {},
|
|
72
|
+
observeWait = null }) {
|
|
71
73
|
this.#stretchCopied = stretchCopied;
|
|
74
|
+
this.#observeWait = observeWait;
|
|
72
75
|
this.#openOf = openOf;
|
|
73
76
|
this.#resolve = resolve;
|
|
74
77
|
this.#read = readRanges;
|
|
@@ -103,6 +106,14 @@ export class EncodeInputs {
|
|
|
103
106
|
`${rank ? `, rank ${rank.rank} of ${rank.topRank}` : ""})${detail}`);
|
|
104
107
|
}
|
|
105
108
|
|
|
109
|
+
#observe(request, phase, fields = {}) {
|
|
110
|
+
const sourceKey = request.output?.file?.sourceKey;
|
|
111
|
+
if (typeof sourceKey !== "string") return;
|
|
112
|
+
const process = request.output?.spec?.carries === "audio-only" ? "P8.a" : "P8.v";
|
|
113
|
+
this.#observeWait?.(sourceKey, "process", { process, phase, at: performance.now(),
|
|
114
|
+
output: request.output.outputKey ?? request.output.id, from: request.from, to: request.to, ...fields });
|
|
115
|
+
}
|
|
116
|
+
|
|
106
117
|
/** The container's statement of an interval, its time added to the request's account. */
|
|
107
118
|
async #resolveTimed(timing, output, from, to, isCurrent = null) {
|
|
108
119
|
const startedAt = this.#now();
|
|
@@ -258,7 +269,8 @@ export class EncodeInputs {
|
|
|
258
269
|
return existing.result;
|
|
259
270
|
}
|
|
260
271
|
if (existing?.pending || existing?.revision === this.#revision || existing?.result?.kind === "terminal") return null;
|
|
261
|
-
const request = existing ?? { output, from, to, key, serves };
|
|
272
|
+
const request = existing ?? { output, from, to, key, serves, traceStartedAt: performance.now() };
|
|
273
|
+
if (!existing) this.#observe(request, "begin", { request: key });
|
|
262
274
|
this.#requests.set(key, request);
|
|
263
275
|
this.#prepare(request);
|
|
264
276
|
return null;
|
|
@@ -403,6 +415,7 @@ export class EncodeInputs {
|
|
|
403
415
|
request.timing ??= { askedAt: this.#now(), attempts: 0, resolutions: 0, resolveMs: 0,
|
|
404
416
|
reads: 0, queuedMs: 0, readMs: 0, heldMs: 0, budgetMs: 0 };
|
|
405
417
|
request.timing.attempts += 1;
|
|
418
|
+
this.#observe(request, "attempt", { request: request.key, attempt: request.timing.attempts });
|
|
406
419
|
request.promise = (async () => {
|
|
407
420
|
// A request the plan withdraws stops its reads: a soundtrack piece's
|
|
408
421
|
// packet walk, left to run, held the file's reads and the piece the
|
|
@@ -449,10 +462,20 @@ export class EncodeInputs {
|
|
|
449
462
|
: `, copied in ${result.copyMs}ms`) : "")
|
|
450
463
|
: `${result.kind}${result.reason ? ` ${result.reason}` : ""}${Number.isFinite(result.bytes) ? ` ${result.bytes} bytes` : ""}`,
|
|
451
464
|
describeTiming(request.timing, this.#now()));
|
|
465
|
+
if (result.kind === "result" || result.kind === "terminal") {
|
|
466
|
+
this.#observe(request, "end", { request: request.key, startedAt: request.traceStartedAt,
|
|
467
|
+
endedAt: performance.now(), outcome: result.kind, reason: result.reason ?? null,
|
|
468
|
+
bytes: result.bytes ?? null, attempts: request.timing.attempts });
|
|
469
|
+
} else {
|
|
470
|
+
this.#observe(request, "waiting", { request: request.key, reason: result.kind,
|
|
471
|
+
bytes: result.bytes ?? null, attempts: request.timing.attempts });
|
|
472
|
+
}
|
|
452
473
|
if (result.kind === "result" || result.kind === "terminal") this.#changed(request.output, result);
|
|
453
474
|
})().catch(error => {
|
|
454
475
|
if (this.#requests.get(request.key) === request) {
|
|
455
476
|
request.result = { kind: "terminal", reason: "input-preparation-failed", message: error.message };
|
|
477
|
+
this.#observe(request, "end", { request: request.key, startedAt: request.traceStartedAt,
|
|
478
|
+
endedAt: performance.now(), outcome: "error", reason: "input-preparation-failed", attempts: request.timing.attempts });
|
|
456
479
|
this.#failed(request.output, error);
|
|
457
480
|
}
|
|
458
481
|
}).finally(() => {
|
|
@@ -285,6 +285,21 @@ export class EncodeRuns {
|
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
noteFirstInputBytes(runToken, fileIndex, bytes) {
|
|
289
|
+
const run = this.#runsByInputToken.get(runToken);
|
|
290
|
+
if (!run || run.firstWaitTraceInputAt !== undefined) return;
|
|
291
|
+
const endedAt = performance.now();
|
|
292
|
+
run.firstWaitTraceInputAt = endedAt;
|
|
293
|
+
const { sourceKey, process } = run.waitTraceInput ?? {};
|
|
294
|
+
this.#host.observeWait?.(sourceKey, "process", { process, phase: "end", runToken, fileIndex, bytes,
|
|
295
|
+
startedAt: run.startedAt, endedAt, durationMs: endedAt - run.startedAt });
|
|
296
|
+
const f2Process = process === "F1.a" ? "F2.a" : "F2.v";
|
|
297
|
+
run.waitTraceInput.f2Process = f2Process;
|
|
298
|
+
run.waitTraceInput.f2StartedAt = endedAt;
|
|
299
|
+
this.#host.observeWait?.(sourceKey, "process", { process: f2Process, phase: "begin", runToken,
|
|
300
|
+
fileIndex, bytes, startedAt: endedAt });
|
|
301
|
+
}
|
|
302
|
+
|
|
288
303
|
originalInputOf(runToken) {
|
|
289
304
|
return this.#runsByInputToken.get(runToken)?.originalInput ?? null;
|
|
290
305
|
}
|
|
@@ -561,6 +576,7 @@ export class EncodeRuns {
|
|
|
561
576
|
}
|
|
562
577
|
|
|
563
578
|
planEncodersNow() {
|
|
579
|
+
const planStartedAt = performance.now();
|
|
564
580
|
const byOutput = this.#outputsWithSessions();
|
|
565
581
|
for (const [address, sessions] of byOutput) {
|
|
566
582
|
// From the TIMELINE, which is where how a file is cut has lived since
|
|
@@ -609,7 +625,22 @@ export class EncodeRuns {
|
|
|
609
625
|
) ?? []
|
|
610
626
|
);
|
|
611
627
|
}
|
|
628
|
+
for (const [address, sessions] of byOutput) {
|
|
629
|
+
const sourceKey = sessions[0]?.file?.sourceKey;
|
|
630
|
+
if (typeof sourceKey !== "string") continue;
|
|
631
|
+
const process = sessions[0]?.spec?.carries === "audio-only" ? "P7.a" : "P7.v";
|
|
632
|
+
this.#host.observeWait?.(sourceKey, "process", { process, phase: "begin", at: planStartedAt,
|
|
633
|
+
output: address, sessions: sessions.length });
|
|
634
|
+
}
|
|
612
635
|
this.#host.encodeOrchestrator.reconcile();
|
|
636
|
+
const planEndedAt = performance.now();
|
|
637
|
+
for (const [address, sessions] of byOutput) {
|
|
638
|
+
const sourceKey = sessions[0]?.file?.sourceKey;
|
|
639
|
+
if (typeof sourceKey !== "string") continue;
|
|
640
|
+
const process = sessions[0]?.spec?.carries === "audio-only" ? "P7.a" : "P7.v";
|
|
641
|
+
this.#host.observeWait?.(sourceKey, "process", { process, phase: "end", at: planEndedAt,
|
|
642
|
+
startedAt: planStartedAt, endedAt: planEndedAt, output: address, sessions: sessions.length });
|
|
643
|
+
}
|
|
613
644
|
}
|
|
614
645
|
|
|
615
646
|
/**
|
|
@@ -1164,6 +1195,13 @@ export class EncodeRuns {
|
|
|
1164
1195
|
// It belongs to no requested output segment and must not condemn the
|
|
1165
1196
|
// complete input or overwrite a neighboring run's segment.
|
|
1166
1197
|
if (Number.isInteger(index) && (index < safeIndex || index > run.to)) return null;
|
|
1198
|
+
if (index === safeIndex && run.waitTraceInput?.f2StartedAt && !run.waitTraceInput.f2EndedAt) {
|
|
1199
|
+
const endedAt = performance.now();
|
|
1200
|
+
run.waitTraceInput.f2EndedAt = endedAt;
|
|
1201
|
+
this.#host.observeWait?.(session.file.sourceKey, "process", { process: run.waitTraceInput.f2Process,
|
|
1202
|
+
phase: "end", runToken: inputToken, segment: index, startedAt: run.waitTraceInput.f2StartedAt,
|
|
1203
|
+
endedAt, durationMs: endedAt - run.waitTraceInput.f2StartedAt, outcome: "piece-closed" });
|
|
1204
|
+
}
|
|
1167
1205
|
let bytes = null;
|
|
1168
1206
|
const address = session.outputKey ?? "";
|
|
1169
1207
|
// Read before anything rewrites it: when the encoder finished it.
|
|
@@ -1223,6 +1261,13 @@ export class EncodeRuns {
|
|
|
1223
1261
|
this.#host.noteRunSpeedMeasured?.(session, run);
|
|
1224
1262
|
},
|
|
1225
1263
|
onEnded: (ended) => {
|
|
1264
|
+
if (run.waitTraceInput?.f2StartedAt && !run.waitTraceInput.f2EndedAt) {
|
|
1265
|
+
const endedAt = performance.now();
|
|
1266
|
+
run.waitTraceInput.f2EndedAt = endedAt;
|
|
1267
|
+
this.#host.observeWait?.(session.file.sourceKey, "process", { process: run.waitTraceInput.f2Process,
|
|
1268
|
+
phase: "end", runToken: inputToken, segment: safeIndex, startedAt: run.waitTraceInput.f2StartedAt,
|
|
1269
|
+
endedAt, durationMs: endedAt - run.waitTraceInput.f2StartedAt, outcome: ended.ending ?? "ended" });
|
|
1270
|
+
}
|
|
1226
1271
|
this.#runsByInputToken.delete(inputToken);
|
|
1227
1272
|
if (admittedInput.original) {
|
|
1228
1273
|
const reads = admittedInput.reads?.();
|
|
@@ -1244,6 +1289,11 @@ export class EncodeRuns {
|
|
|
1244
1289
|
this.#host.encodersChanged?.();
|
|
1245
1290
|
}
|
|
1246
1291
|
});
|
|
1292
|
+
const f1Process = session.spec.carries === "audio-only" ? "F1.a" : "F1.v";
|
|
1293
|
+
run.waitTraceInput = { sourceKey: session.file.sourceKey, process: f1Process };
|
|
1294
|
+
this.#host.observeWait?.(session.file.sourceKey, "process", { process: f1Process,
|
|
1295
|
+
phase: "begin", runToken: inputToken, startedAt: run.startedAt, output: session.outputKey,
|
|
1296
|
+
from: run.from, to: run.to });
|
|
1247
1297
|
if (admittedInput.original) {
|
|
1248
1298
|
run.originalInput = admittedInput;
|
|
1249
1299
|
// Every number of the stretch answers to its one key, so a failure
|
|
@@ -164,9 +164,8 @@ export class ContainerOrchestrator {
|
|
|
164
164
|
const result = await this.#inspectRead({ ...params, isCurrent }, statement);
|
|
165
165
|
const endedAt = performance.now();
|
|
166
166
|
params.onTimed?.(statement, startedAt - askedAt, endedAt - startedAt);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
result: result.kind, reason: result.reason, ranges: result.ranges });
|
|
167
|
+
params.onObserved?.({ statement, askedAt, startedAt, endedAt, queuedMs: startedAt - askedAt,
|
|
168
|
+
readMs: endedAt - startedAt, cached: aside, result: result.kind, reason: result.reason, ranges: result.ranges });
|
|
170
169
|
return { result, revision };
|
|
171
170
|
});
|
|
172
171
|
if (!aside) this.#reads.set(key, reading);
|
|
@@ -110,7 +110,17 @@ export class SegmentServing {
|
|
|
110
110
|
async getFileStream(sessionId, fileName, options = {}) {
|
|
111
111
|
const output = this.#host.outputs.get(sessionId);
|
|
112
112
|
if (!output) return { kind: "not-found" };
|
|
113
|
-
|
|
113
|
+
const isInit = output.segmentFormat?.initFileName !== null && fileName === output.segmentFormat?.initFileName;
|
|
114
|
+
const startedAt = performance.now();
|
|
115
|
+
const answer = await this.#withMaterialRead(output.outputKey, () => this.#getFileStream(sessionId, fileName, options));
|
|
116
|
+
if (isInit && typeof output.file?.sourceKey === "string") {
|
|
117
|
+
const process = output.spec?.carries === "audio-only" ? "P13.a" : "P13.v";
|
|
118
|
+
const fields = { output: output.outputKey, fileName, outcome: answer?.kind ?? "unknown" };
|
|
119
|
+
this.#host.observeWait?.(output.file.sourceKey, "process", { ...fields, process, phase: "begin", at: startedAt });
|
|
120
|
+
this.#host.observeWait?.(output.file.sourceKey, "process", { ...fields, process, phase: "end",
|
|
121
|
+
at: performance.now(), startedAt, endedAt: performance.now() });
|
|
122
|
+
}
|
|
123
|
+
return answer;
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
async #withMaterialRead(key, read) {
|
|
@@ -120,7 +120,7 @@ export class ViewerRequests {
|
|
|
120
120
|
};
|
|
121
121
|
const current = this.#earlyWaits.get(key);
|
|
122
122
|
if (current?.waitId === waitId && current.hasSourceObservation) {
|
|
123
|
-
const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features,
|
|
123
|
+
const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features,
|
|
124
124
|
revision: current.revision, current, waitElapsedMs });
|
|
125
125
|
const changed = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
126
126
|
if (changed) context.revision += 1;
|
|
@@ -136,7 +136,7 @@ export class ViewerRequests {
|
|
|
136
136
|
}
|
|
137
137
|
const sameWait = current?.waitId === waitId;
|
|
138
138
|
const revision = sameWait ? current.revision : 0;
|
|
139
|
-
const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
|
|
139
|
+
const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
|
|
140
140
|
waitElapsedMs });
|
|
141
141
|
const changed = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
142
142
|
if (sameWait && changed) context.revision += 1;
|
|
@@ -186,7 +186,7 @@ export class ViewerRequests {
|
|
|
186
186
|
const current = this.#earlyWaits.get(key);
|
|
187
187
|
const sameWait = current?.waitId === waitId;
|
|
188
188
|
const revision = sameWait ? current.revision : 0;
|
|
189
|
-
const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
|
|
189
|
+
const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
|
|
190
190
|
sourceObserved: true, waitElapsedMs });
|
|
191
191
|
const featuresChanged = this.#recordEarlyWaitSnapshot(context, context.features);
|
|
192
192
|
const elapsedMs = waitElapsedSince(context, waitElapsedMs);
|
|
@@ -231,6 +231,9 @@ export class ViewerRequests {
|
|
|
231
231
|
logger.info(`early wait forecast ${JSON.stringify({ waitId: context.waitId, consumer: context.consumerId,
|
|
232
232
|
generation: context.generation, features: context.features, elapsedMs, revision: context.revision,
|
|
233
233
|
predicted: { remainingSeconds: seconds, totalSeconds, basis, ...estimate } })}`);
|
|
234
|
+
this.#host.observeWait?.(context.sourceKey, "process", { process: "P15", phase: "event",
|
|
235
|
+
waitId: context.waitId, consumerId: context.consumerId, generation: context.generation,
|
|
236
|
+
elapsedMs, revision: context.revision, remainingSeconds: seconds, totalSeconds, basis, ...estimate });
|
|
234
237
|
context.loggedForecastRevision = context.revision;
|
|
235
238
|
}
|
|
236
239
|
|
|
@@ -258,12 +261,13 @@ export class ViewerRequests {
|
|
|
258
261
|
early.updatedAt = Date.now();
|
|
259
262
|
}
|
|
260
263
|
|
|
261
|
-
#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
|
|
264
|
+
#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
|
|
262
265
|
sourceObserved = false, waitElapsedMs }) {
|
|
263
266
|
const sameWait = current?.waitId === waitId;
|
|
264
267
|
const context = sameWait ? current : {
|
|
265
268
|
consumerId,
|
|
266
269
|
generation,
|
|
270
|
+
sourceKey,
|
|
267
271
|
fileIndex,
|
|
268
272
|
waitId,
|
|
269
273
|
snapshots: [],
|
|
@@ -279,7 +283,7 @@ export class ViewerRequests {
|
|
|
279
283
|
? Math.max(0, measuredAt - context.featuresMeasuredAt)
|
|
280
284
|
: 0;
|
|
281
285
|
const mergedFeatures = mergeWaitFeatures(context.features, features, ageElapsedMs);
|
|
282
|
-
Object.assign(context, { consumerId, generation, fileIndex, waitId, features: mergedFeatures,
|
|
286
|
+
Object.assign(context, { consumerId, generation, sourceKey, fileIndex, waitId, features: mergedFeatures,
|
|
283
287
|
featuresMeasuredAt: measuredAt, startedAt, updatedAt: Date.now(), revision,
|
|
284
288
|
hasSourceObservation: context.hasSourceObservation || sourceObserved });
|
|
285
289
|
this.#earlyWaits.set(`${consumerId}:${waitId}`, context);
|
|
@@ -1034,6 +1038,11 @@ export class ViewerRequests {
|
|
|
1034
1038
|
if (measurement.forecastReason !== forecast.reason || measurement.forecastInputKey !== forecastInputKey) {
|
|
1035
1039
|
measurement.forecastReason = forecast.reason;
|
|
1036
1040
|
measurement.forecastInputKey = forecastInputKey;
|
|
1041
|
+
if (forecast.subtitles) this.#host.observeWait?.(session.file.sourceKey, "process", {
|
|
1042
|
+
process: "C3", phase: "event", fileIndex: session.file.fileIndex,
|
|
1043
|
+
trackIndex: forecast.subtitles.trackIndex, ready: forecast.subtitles.ready,
|
|
1044
|
+
reason: forecast.reason, consumerId
|
|
1045
|
+
});
|
|
1037
1046
|
logger.info(`playback readiness ${session.id} consumer=${consumerId} ${JSON.stringify({
|
|
1038
1047
|
...forecast,
|
|
1039
1048
|
positionSeconds,
|
|
@@ -535,6 +535,7 @@ export function wireOutputs({
|
|
|
535
535
|
});
|
|
536
536
|
// Answering a request for a playlist, an init segment or a segment: from the store when the piece is made and whole, otherwise held until it is.
|
|
537
537
|
parts.serving = new SegmentServing({
|
|
538
|
+
observeWait,
|
|
538
539
|
// What a key names, read back from the key: how to find a gone output's
|
|
539
540
|
// stored pieces and whether they are cut at given times.
|
|
540
541
|
segmentFormatOfKey,
|
|
@@ -624,6 +625,7 @@ export function wireOutputs({
|
|
|
624
625
|
});
|
|
625
626
|
// What a viewer asks for: an output of a file, a position, a report of progress.
|
|
626
627
|
parts.viewerRequests = new ViewerRequests({
|
|
628
|
+
observeWait,
|
|
627
629
|
// Whether this machine has a place for one more viewer on this output —
|
|
628
630
|
// asked in the same synchronous stretch as they are put on it.
|
|
629
631
|
admitsWatching: (output) => parts.admission.admitsWatching(output?.outputKey ?? ""),
|
|
@@ -1189,6 +1191,7 @@ export function wireOutputs({
|
|
|
1189
1191
|
// The piece's rank in the output's own map, said with an input's state.
|
|
1190
1192
|
rankAt: (output, index) => parts.encodeOrchestrator?.rankAt?.(output.outputKey ?? "", index) ?? null,
|
|
1191
1193
|
log: line => logger.info(line),
|
|
1194
|
+
observeWait,
|
|
1192
1195
|
changed: (output, result) => {
|
|
1193
1196
|
if (result.kind === "terminal") {
|
|
1194
1197
|
logger.warn(`encode input output=${output.outputKey} terminal=${result.reason}: ${result.message ?? ""}` +
|
|
@@ -38,6 +38,7 @@ export class PieceTimings {
|
|
|
38
38
|
*/
|
|
39
39
|
asked(piece, offset, peer, now) {
|
|
40
40
|
let record = this.#pieces.get(piece);
|
|
41
|
+
const firstAsk = !record;
|
|
41
42
|
if (!record) {
|
|
42
43
|
record = { firstAskAt: now, asks: 0, peers: new Set(), blocks: new Map(), arrived: new Set(), askedAgain: 0, arrivedTwice: 0,
|
|
43
44
|
slowestMs: 0, slowestPeer: null, lastInFlightMs: 0, lastPeer: null };
|
|
@@ -47,6 +48,7 @@ export class PieceTimings {
|
|
|
47
48
|
record.peers.add(peer);
|
|
48
49
|
if (!record.blocks.has(offset)) record.blocks.set(offset, now);
|
|
49
50
|
else record.askedAgain += 1;
|
|
51
|
+
return firstAsk;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -969,6 +969,8 @@ export class TorrentPool {
|
|
|
969
969
|
* @type {WeakMap<import("webtorrent").Torrent, { addedAt: number, firstPeerAt: number | null }>}
|
|
970
970
|
*/
|
|
971
971
|
#swarmTimingByTorrent = new WeakMap();
|
|
972
|
+
#waitTraceByTorrent = new WeakMap();
|
|
973
|
+
#waitTraceHooks = new WeakSet();
|
|
972
974
|
|
|
973
975
|
/**
|
|
974
976
|
* Sources every file of which this proxy holds whole.
|
|
@@ -1502,7 +1504,60 @@ export class TorrentPool {
|
|
|
1502
1504
|
* @param {import("webtorrent").Torrent} torrent
|
|
1503
1505
|
* @returns {void}
|
|
1504
1506
|
*/
|
|
1505
|
-
#attachSwarmDiagnostics(torrent) {
|
|
1507
|
+
#attachSwarmDiagnostics(torrent, waitTrace = null) {
|
|
1508
|
+
if (waitTrace?.trace === true && typeof waitTrace.sourceKey === "string") {
|
|
1509
|
+
this.#waitTraceByTorrent.set(torrent, waitTrace);
|
|
1510
|
+
}
|
|
1511
|
+
const trace = (process, phase, fields = {}) => {
|
|
1512
|
+
const context = this.#waitTraceByTorrent.get(torrent);
|
|
1513
|
+
if (context?.trace === true) context.emitWaitTrace?.(context.sourceKey, process, phase, fields);
|
|
1514
|
+
};
|
|
1515
|
+
const wrap = (method, process) => {
|
|
1516
|
+
const original = torrent[method];
|
|
1517
|
+
if (typeof original !== "function") return;
|
|
1518
|
+
torrent[method] = function (...args) {
|
|
1519
|
+
const startedAt = performance.now();
|
|
1520
|
+
trace(process, "begin", { startedAt });
|
|
1521
|
+
try {
|
|
1522
|
+
const result = original.apply(this, args);
|
|
1523
|
+
if (result && typeof result.then === "function") {
|
|
1524
|
+
return result.then(value => {
|
|
1525
|
+
trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "resolved" });
|
|
1526
|
+
return value;
|
|
1527
|
+
}, error => {
|
|
1528
|
+
trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "error",
|
|
1529
|
+
error: String(error?.message ?? error) });
|
|
1530
|
+
throw error;
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "returned" });
|
|
1534
|
+
return result;
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "error",
|
|
1537
|
+
error: String(error?.message ?? error) });
|
|
1538
|
+
throw error;
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
};
|
|
1542
|
+
if (waitTrace?.trace === true && !this.#waitTraceHooks.has(torrent)) {
|
|
1543
|
+
this.#waitTraceHooks.add(torrent);
|
|
1544
|
+
wrap("_onListening", "W1");
|
|
1545
|
+
wrap("_startDiscovery", "W2");
|
|
1546
|
+
wrap("_onStore", "W6");
|
|
1547
|
+
const verify = torrent._verifyPieces;
|
|
1548
|
+
if (typeof verify === "function") {
|
|
1549
|
+
torrent._verifyPieces = function (callback, ...args) {
|
|
1550
|
+
const startedAt = performance.now();
|
|
1551
|
+
trace("W5", "begin", { startedAt });
|
|
1552
|
+
return verify.call(this, (...results) => {
|
|
1553
|
+
trace("W5", "end", { startedAt, endedAt: performance.now(), outcome: "verified" });
|
|
1554
|
+
callback?.(...results);
|
|
1555
|
+
}, ...args);
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
torrent.on("metadata", metadata => trace("W4", "event", { files: metadata?.files?.length ?? null }));
|
|
1559
|
+
torrent.on("wire", wire => trace("W3", "event", { peer: String(wire?.remoteAddress ?? "?") }));
|
|
1560
|
+
}
|
|
1506
1561
|
// Attached ONCE per torrent. WebTorrent answers a duplicate add by handing
|
|
1507
1562
|
// back the torrent it already has, and this used to run again on it: a
|
|
1508
1563
|
// torrent with twelve connections and a first peer five minutes old had its
|
|
@@ -1510,7 +1565,8 @@ export class TorrentPool {
|
|
|
1510
1565
|
// connection printed "first peer connected after 0.3s" — a false statement
|
|
1511
1566
|
// about a swarm that had been healthy for minutes. The same film opened
|
|
1512
1567
|
// once as a .torrent and once as a magnet is exactly that case, and the
|
|
1513
|
-
// roadmap already records it happening.
|
|
1568
|
+
// roadmap already records it happening. Trace hooks above are installed
|
|
1569
|
+
// separately and only for an explicitly traced source.
|
|
1514
1570
|
if (this.#swarmTimingByTorrent.has(torrent)) {
|
|
1515
1571
|
return;
|
|
1516
1572
|
}
|
|
@@ -1547,6 +1603,7 @@ export class TorrentPool {
|
|
|
1547
1603
|
// its blocks were in flight, and from whom (`piece-timing.js`). One line
|
|
1548
1604
|
// per piece: a 2.3 GB film is 563.
|
|
1549
1605
|
const timings = new PieceTimings();
|
|
1606
|
+
const waitTracePieceStarts = new Map();
|
|
1550
1607
|
tailRepeats.set(torrent, new TailRepeat({ torrent, requiredPieces: () => demandFor(torrent).selection.requiredPieces(),
|
|
1551
1608
|
log: (line) => logger.info(`download ${label()} ${line}`) }));
|
|
1552
1609
|
torrent.on("wire", (wire) => {
|
|
@@ -1561,17 +1618,33 @@ export class TorrentPool {
|
|
|
1561
1618
|
const answer = request.call(this, index, offset, length, callback);
|
|
1562
1619
|
if (this.requests.length > queued) {
|
|
1563
1620
|
const now = Date.now();
|
|
1564
|
-
timings.asked(index, offset, peer, now);
|
|
1621
|
+
const firstAsk = timings.asked(index, offset, peer, now);
|
|
1622
|
+
if (firstAsk) {
|
|
1623
|
+
const startedAt = performance.now();
|
|
1624
|
+
waitTracePieceStarts.set(index, startedAt);
|
|
1625
|
+
trace("W8", "begin", { piece: index, startedAt, firstOffset: offset, blockLength: length });
|
|
1626
|
+
}
|
|
1565
1627
|
// When this wire was asked for the block: how long it has held it is
|
|
1566
1628
|
// what decides whether a second peer is asked (`duplicateTailFor`).
|
|
1567
1629
|
this.requests.at(-1).askedAt = now;
|
|
1568
1630
|
}
|
|
1569
1631
|
return answer;
|
|
1570
1632
|
};
|
|
1571
|
-
wire.on("piece", (index, offset) =>
|
|
1633
|
+
wire.on("piece", (index, offset) => {
|
|
1634
|
+
timings.arrived(index, offset, peer, Date.now());
|
|
1635
|
+
});
|
|
1572
1636
|
});
|
|
1573
1637
|
torrent.on("verified", (piece) => {
|
|
1574
|
-
|
|
1638
|
+
const pieceTiming = timings.verified(piece, Date.now());
|
|
1639
|
+
const startedAt = waitTracePieceStarts.get(piece);
|
|
1640
|
+
if (startedAt !== undefined) {
|
|
1641
|
+
waitTracePieceStarts.delete(piece);
|
|
1642
|
+
const endedAt = performance.now();
|
|
1643
|
+
trace("W8", "end", { piece, startedAt, endedAt, durationMs: endedAt - startedAt,
|
|
1644
|
+
outcome: "verified", asks: pieceTiming?.asks ?? null, peers: pieceTiming?.peers ?? null,
|
|
1645
|
+
askedAgain: pieceTiming?.askedAgain ?? null, slowestBlockMs: pieceTiming?.slowestMs ?? null });
|
|
1646
|
+
}
|
|
1647
|
+
logger.info(`download ${label()} verified piece=${piece}${describePieceTiming(pieceTiming)}`);
|
|
1575
1648
|
});
|
|
1576
1649
|
|
|
1577
1650
|
// The first connected peer, said once, because the wait for it can BE the
|
|
@@ -1694,7 +1767,7 @@ export class TorrentPool {
|
|
|
1694
1767
|
* @param {string} source - Magnet URI or base64-encoded .torrent bytes.
|
|
1695
1768
|
* @returns {Promise<import("webtorrent").Torrent>}
|
|
1696
1769
|
*/
|
|
1697
|
-
async getTorrent(sourceType, source) {
|
|
1770
|
+
async getTorrent(sourceType, source, waitTrace = null) {
|
|
1698
1771
|
// Keyed by the torrent's own infohash, not the source bytes — a magnet
|
|
1699
1772
|
// and a `.torrent` file for the same content must name the same swarm.
|
|
1700
1773
|
const key = await deriveSourceKey(sourceType, source);
|
|
@@ -1702,6 +1775,7 @@ export class TorrentPool {
|
|
|
1702
1775
|
// Already resolved — return immediately.
|
|
1703
1776
|
const existing = this.torrents.get(key);
|
|
1704
1777
|
if (existing) {
|
|
1778
|
+
this.#attachSwarmDiagnostics(existing, waitTrace);
|
|
1705
1779
|
this.#lastAccess.set(existing, Date.now());
|
|
1706
1780
|
return existing;
|
|
1707
1781
|
}
|
|
@@ -1740,7 +1814,7 @@ export class TorrentPool {
|
|
|
1740
1814
|
);
|
|
1741
1815
|
existing.destroy({ destroyStore: false }, () => {
|
|
1742
1816
|
this.#pending.delete(key);
|
|
1743
|
-
resolve(this.getTorrent(sourceType, source));
|
|
1817
|
+
resolve(this.getTorrent(sourceType, source, waitTrace));
|
|
1744
1818
|
});
|
|
1745
1819
|
return;
|
|
1746
1820
|
}
|
|
@@ -1792,10 +1866,10 @@ export class TorrentPool {
|
|
|
1792
1866
|
this.torrents.set(key, replacement);
|
|
1793
1867
|
this.#lastAccess.set(replacement, Date.now());
|
|
1794
1868
|
this.#pending.delete(key);
|
|
1795
|
-
this.#attachSwarmDiagnostics(replacement);
|
|
1869
|
+
this.#attachSwarmDiagnostics(replacement, waitTrace);
|
|
1796
1870
|
resolve(replacement);
|
|
1797
1871
|
});
|
|
1798
|
-
this.#attachSwarmDiagnostics(addedReplacement);
|
|
1872
|
+
this.#attachSwarmDiagnostics(addedReplacement, waitTrace);
|
|
1799
1873
|
});
|
|
1800
1874
|
return;
|
|
1801
1875
|
}
|
|
@@ -1816,6 +1890,10 @@ export class TorrentPool {
|
|
|
1816
1890
|
// piece across threads detached memory still in use. Ours owns what it
|
|
1817
1891
|
// hands out, holds pieces in shared memory the main thread can read
|
|
1818
1892
|
// directly, and spills to disk instead of losing them.
|
|
1893
|
+
const skipVerify = this.#wholeSources.has(key);
|
|
1894
|
+
if (skipVerify && waitTrace?.trace === true) {
|
|
1895
|
+
waitTrace.emitWaitTrace?.(waitTrace.sourceKey, "W5", "event", { outcome: "skipped" });
|
|
1896
|
+
}
|
|
1819
1897
|
const added = this.client.add(torrentId, {
|
|
1820
1898
|
store: this.#pieceStore,
|
|
1821
1899
|
storeCacheSlots: 0,
|
|
@@ -1831,7 +1909,7 @@ export class TorrentPool {
|
|
|
1831
1909
|
// whole: what would be verified was written out of pieces this client
|
|
1832
1910
|
// had hashed, and the file's size was checked against what the torrent
|
|
1833
1911
|
// says. See `#wholeSources`.
|
|
1834
|
-
skipVerify
|
|
1912
|
+
skipVerify
|
|
1835
1913
|
}, (readyTorrent) => {
|
|
1836
1914
|
this.client.off("error", onError);
|
|
1837
1915
|
this.torrents.set(key, readyTorrent);
|
|
@@ -1842,7 +1920,7 @@ export class TorrentPool {
|
|
|
1842
1920
|
// back the one it already had. Watching only what `add` returned would
|
|
1843
1921
|
// leave the survivor unwatched. Attaching twice costs nothing — the
|
|
1844
1922
|
// guard makes the second call a no-op when it is the same object.
|
|
1845
|
-
this.#attachSwarmDiagnostics(readyTorrent);
|
|
1923
|
+
this.#attachSwarmDiagnostics(readyTorrent, waitTrace);
|
|
1846
1924
|
// On the clock from the moment it exists. A torrent nobody ever states
|
|
1847
1925
|
// anything about — a file list fetched and never played — used to be
|
|
1848
1926
|
// held for the life of the process, because the only thing that ever
|
|
@@ -1858,7 +1936,7 @@ export class TorrentPool {
|
|
|
1858
1936
|
// `wire` is emitted at the moment of connection and never replayed. The
|
|
1859
1937
|
// torrent that waited minutes for its first peer would have been the one
|
|
1860
1938
|
// case this could not measure.
|
|
1861
|
-
this.#attachSwarmDiagnostics(added);
|
|
1939
|
+
this.#attachSwarmDiagnostics(added, waitTrace);
|
|
1862
1940
|
});
|
|
1863
1941
|
|
|
1864
1942
|
this.#pending.set(key, promise);
|
|
@@ -137,11 +137,13 @@ export class TorrentWorkerClient {
|
|
|
137
137
|
#onSourceForgotten;
|
|
138
138
|
/** Told when what this proxy holds changed; see `Event.HOLDINGS_CHANGED`. */
|
|
139
139
|
#onHoldingsChanged;
|
|
140
|
+
#onWaitTrace;
|
|
140
141
|
|
|
141
142
|
/**
|
|
142
143
|
* @param {{ memoryBytes?: number, stateDir?: string, onPiecesArrived?: (event: object) => void }} [options]
|
|
143
144
|
*/
|
|
144
|
-
constructor({ memoryBytes, stateDir, heapSnapshots = false, onPiecesArrived, onSourceForgotten, onHoldingsChanged
|
|
145
|
+
constructor({ memoryBytes, stateDir, heapSnapshots = false, onPiecesArrived, onSourceForgotten, onHoldingsChanged,
|
|
146
|
+
onWaitTrace } = {}) {
|
|
145
147
|
this.#worker = new Worker(fileURLToPath(WORKER_URL), {
|
|
146
148
|
// `stateDir` travels because the worker writes heap snapshots of its own
|
|
147
149
|
// isolate there. It cannot choose a directory any other way: a worker may
|
|
@@ -153,6 +155,7 @@ export class TorrentWorkerClient {
|
|
|
153
155
|
this.#onPiecesArrived = onPiecesArrived ?? (() => undefined);
|
|
154
156
|
this.#onSourceForgotten = onSourceForgotten ?? (() => undefined);
|
|
155
157
|
this.#onHoldingsChanged = onHoldingsChanged ?? (() => undefined);
|
|
158
|
+
this.#onWaitTrace = onWaitTrace ?? (() => undefined);
|
|
156
159
|
|
|
157
160
|
this.#worker.on("message", (message) => {
|
|
158
161
|
// A failed read must fail its stream. This is checked BEFORE the caller
|
|
@@ -254,6 +257,9 @@ export class TorrentWorkerClient {
|
|
|
254
257
|
// taken twice on two different histories.
|
|
255
258
|
writeAlreadyDecided(message.level ?? "info", `torrent-worker: ${message.message}`);
|
|
256
259
|
break;
|
|
260
|
+
case Event.WAIT_TRACE:
|
|
261
|
+
this.#onWaitTrace(message);
|
|
262
|
+
break;
|
|
257
263
|
case Event.FILE_COMPLETE:
|
|
258
264
|
// Recorded here so the stream route can answer from the file without
|
|
259
265
|
// going near the torrent thread at all.
|
|
@@ -346,8 +352,8 @@ export class TorrentWorkerClient {
|
|
|
346
352
|
* @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
|
|
347
353
|
* @returns {Promise<{ infoHash: string, name: string, files: { index: number, name: string, path: string, length: number }[] }>}
|
|
348
354
|
*/
|
|
349
|
-
async addSource({ sourceKey, sourceType, source }) {
|
|
350
|
-
return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source });
|
|
355
|
+
async addSource({ sourceKey, sourceType, source, trace = false }) {
|
|
356
|
+
return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source, trace });
|
|
351
357
|
}
|
|
352
358
|
|
|
353
359
|
/**
|
|
@@ -675,8 +681,8 @@ export class TorrentWorkerClient {
|
|
|
675
681
|
* @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
|
|
676
682
|
* @returns {Promise<{ infoHash: string, name: string, pieceLength: number, sourceKey: string, files: object[] }>}
|
|
677
683
|
*/
|
|
678
|
-
async getTorrent({ sourceKey, sourceType, source }) {
|
|
679
|
-
const info = await this.addSource({ sourceKey, sourceType, source });
|
|
684
|
+
async getTorrent({ sourceKey, sourceType, source, trace = false }) {
|
|
685
|
+
const info = await this.addSource({ sourceKey, sourceType, source, trace });
|
|
680
686
|
const client = this;
|
|
681
687
|
return {
|
|
682
688
|
infoHash: info.infoHash,
|
|
@@ -32,6 +32,7 @@ export class WorkerTorrentPool {
|
|
|
32
32
|
#torrents = new Map();
|
|
33
33
|
#pending = new Map();
|
|
34
34
|
#observed;
|
|
35
|
+
#isWaitTraced;
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* @param {{ memoryBytes?: number, stateDir?: string }} [options]
|
|
@@ -39,6 +40,7 @@ export class WorkerTorrentPool {
|
|
|
39
40
|
*/
|
|
40
41
|
constructor(options = {}, client = null) {
|
|
41
42
|
this.#observed = options.onObserved;
|
|
43
|
+
this.#isWaitTraced = options.isWaitTraced ?? (() => false);
|
|
42
44
|
this.#client = client ?? new TorrentWorkerClient({ ...options, onSourceForgotten: event => {
|
|
43
45
|
this.forget(event.sourceKey);
|
|
44
46
|
options.onSourceForgotten?.(event);
|
|
@@ -52,7 +54,7 @@ export class WorkerTorrentPool {
|
|
|
52
54
|
* @param {string} source
|
|
53
55
|
* @returns {Promise<object>}
|
|
54
56
|
*/
|
|
55
|
-
async getTorrent(sourceType, source) {
|
|
57
|
+
async getTorrent(sourceType, source, options = {}) {
|
|
56
58
|
const startedAt = performance.now();
|
|
57
59
|
const sourceKey = await deriveSourceKey(sourceType, source);
|
|
58
60
|
const observed = outcome => this.#observed?.({ sourceKey, startedAt, endedAt: performance.now(), outcome });
|
|
@@ -63,7 +65,8 @@ export class WorkerTorrentPool {
|
|
|
63
65
|
}
|
|
64
66
|
const joined = this.#pending.get(sourceKey);
|
|
65
67
|
if (joined) return joined.finally(() => observed("joined"));
|
|
66
|
-
const pending = this.#client.getTorrent({ sourceKey, sourceType, source
|
|
68
|
+
const pending = this.#client.getTorrent({ sourceKey, sourceType, source,
|
|
69
|
+
trace: options.trace === true || this.#isWaitTraced(sourceKey) }).then(torrent => {
|
|
67
70
|
if (this.#pending.get(sourceKey) !== pending) {
|
|
68
71
|
const error = new Error("Source was forgotten while its metadata was being read.");
|
|
69
72
|
error.code = "SOURCE_FORGOTTEN";
|
|
@@ -152,6 +152,8 @@ export const Event = {
|
|
|
152
152
|
CHUNK_ACK: "chunk-ack",
|
|
153
153
|
/** A log line, so worker output reaches the same place as everything else. */
|
|
154
154
|
LOG: "log",
|
|
155
|
+
/** A structured process observation from the torrent thread. */
|
|
156
|
+
WAIT_TRACE: "wait-trace",
|
|
155
157
|
/**
|
|
156
158
|
* Pieces of these files have arrived.
|
|
157
159
|
*
|
|
@@ -92,6 +92,13 @@ const pool = new TorrentPool({
|
|
|
92
92
|
const torrentsByKey = new Map();
|
|
93
93
|
/** Resolved handles for reads that must never wait for or revive a source. */
|
|
94
94
|
const resolvedTorrents = new Map();
|
|
95
|
+
const waitTraceSources = new Set();
|
|
96
|
+
|
|
97
|
+
function emitWaitTrace(sourceKey, process, phase, fields = {}) {
|
|
98
|
+
if (!waitTraceSources.has(sourceKey)) return;
|
|
99
|
+
parentPort.postMessage({ type: Event.WAIT_TRACE, version: 1, clock: "torrent-worker", sourceKey,
|
|
100
|
+
process, phase, at: performance.now(), ...fields });
|
|
101
|
+
}
|
|
95
102
|
|
|
96
103
|
/** In-flight reads, so a cancel can stop one mid-body. */
|
|
97
104
|
const readsById = new Map();
|
|
@@ -327,6 +334,7 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
|
327
334
|
async function runCommand(command, params, id) {
|
|
328
335
|
switch (command) {
|
|
329
336
|
case Command.ADD_SOURCE: {
|
|
337
|
+
if (params.trace === true) waitTraceSources.add(params.sourceKey);
|
|
330
338
|
const retained = wholeSources.describe(params.sourceKey);
|
|
331
339
|
if (retained) {
|
|
332
340
|
announceWholeFiles(params.sourceKey, retained);
|
|
@@ -338,7 +346,10 @@ async function runCommand(command, params, id) {
|
|
|
338
346
|
// racing to open the same torrent into one.
|
|
339
347
|
let pending = torrentsByKey.get(params.sourceKey);
|
|
340
348
|
if (!pending) {
|
|
341
|
-
|
|
349
|
+
emitWaitTrace(params.sourceKey, "W1", "event", { moment: "add-request" });
|
|
350
|
+
pending = pool.getTorrent(params.sourceType, params.source, {
|
|
351
|
+
sourceKey: params.sourceKey, trace: params.trace === true, emitWaitTrace
|
|
352
|
+
});
|
|
342
353
|
torrentsByKey.set(params.sourceKey, pending);
|
|
343
354
|
// A failed add must not be remembered, or every later attempt at this
|
|
344
355
|
// source replays the same failure. The handler also marks the rejection
|
|
@@ -463,9 +474,14 @@ async function runCommand(command, params, id) {
|
|
|
463
474
|
|
|
464
475
|
|
|
465
476
|
case Command.FILE_STATS: {
|
|
477
|
+
const statsStartedAt = performance.now();
|
|
478
|
+
emitWaitTrace(params.sourceKey, "W10", "begin", { startedAt: statsStartedAt,
|
|
479
|
+
fileIndex: params.fileIndex, forecast: params.forecast === true });
|
|
466
480
|
const torrent = await knownTorrent(params.sourceKey);
|
|
467
481
|
if (!torrent) {
|
|
468
482
|
const whole = wholeSources.fileOf(params.sourceKey, params.fileIndex);
|
|
483
|
+
emitWaitTrace(params.sourceKey, "W10", "end", { startedAt: statsStartedAt,
|
|
484
|
+
endedAt: performance.now(), outcome: whole ? "whole-file" : "pending", fileIndex: params.fileIndex });
|
|
469
485
|
return whole ? wholeFileStats(whole.length) : { pending: true };
|
|
470
486
|
}
|
|
471
487
|
// What this file's own interruptions demand, measured by the reader in
|
|
@@ -485,6 +501,9 @@ async function runCommand(command, params, id) {
|
|
|
485
501
|
const end = Math.min(torrent.length, (piece + 1) * torrent.pieceLength);
|
|
486
502
|
if (end > start) ranges.push({ start, end, availableAt });
|
|
487
503
|
}
|
|
504
|
+
emitWaitTrace(params.sourceKey, "W10", "end", { startedAt: statsStartedAt,
|
|
505
|
+
endedAt: performance.now(), outcome: "ok", fileIndex: params.fileIndex,
|
|
506
|
+
forecast: params.forecast === true });
|
|
488
507
|
return {
|
|
489
508
|
...stats,
|
|
490
509
|
...(params.forecast === true ? { downloadForecast: {
|
|
@@ -514,9 +533,12 @@ async function runCommand(command, params, id) {
|
|
|
514
533
|
// zone becomes a window of the register and the swarm is told again
|
|
515
534
|
// (torrent-tv/meta#166).
|
|
516
535
|
const startedAt = performance.now();
|
|
536
|
+
emitWaitTrace(params.sourceKey, "W7", "begin", { fileIndex: params.fileIndex });
|
|
517
537
|
ensureArrivalsWired(params.sourceKey, torrent);
|
|
518
538
|
pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
|
|
519
539
|
torrent.emit("priority-map-changed", params.fileIndex);
|
|
540
|
+
emitWaitTrace(params.sourceKey, "W7", "end", { startedAt, endedAt: performance.now(),
|
|
541
|
+
fileIndex: params.fileIndex });
|
|
520
542
|
return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
|
|
521
543
|
}
|
|
522
544
|
|
|
@@ -918,6 +940,8 @@ function describePieceBuffers() {
|
|
|
918
940
|
* @returns {void}
|
|
919
941
|
*/
|
|
920
942
|
function announceArrivals(sourceKey, torrent, reason = "verified", piece = null) {
|
|
943
|
+
if (Number.isSafeInteger(piece)) emitWaitTrace(sourceKey, "W9", "event", { piece, reason,
|
|
944
|
+
bytes: bytesOfPiece(torrent, piece) });
|
|
921
945
|
const fileIndexes = [...demandFor(torrent).register.files()];
|
|
922
946
|
if (fileIndexes.length === 0) {
|
|
923
947
|
return;
|
|
@@ -20,9 +20,10 @@ export class DownloadMaps {
|
|
|
20
20
|
#clock;
|
|
21
21
|
/** What the torrent is told of the playback zones: as many windows as say the same thing. */
|
|
22
22
|
#pack;
|
|
23
|
+
#observeWait;
|
|
23
24
|
|
|
24
25
|
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
|
|
25
|
-
clock = () => performance.timeOrigin + performance.now(), pack = zones => zones }) {
|
|
26
|
+
clock = () => performance.timeOrigin + performance.now(), pack = zones => zones, observeWait = null }) {
|
|
26
27
|
if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
|
|
27
28
|
this.#publish = publish;
|
|
28
29
|
this.#resolvePlayback = resolvePlayback;
|
|
@@ -30,6 +31,7 @@ export class DownloadMaps {
|
|
|
30
31
|
this.#log = typeof log === "function" ? log : null;
|
|
31
32
|
this.#now = now;
|
|
32
33
|
this.#clock = clock;
|
|
34
|
+
this.#observeWait = typeof observeWait === "function" ? observeWait : null;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
async playback({ sourceKey, fileIndex, durationSeconds, zones }) {
|
|
@@ -117,8 +119,23 @@ export class DownloadMaps {
|
|
|
117
119
|
this.#setZones(file, withRetained(zones, retained));
|
|
118
120
|
this.#emit(file).catch(() => undefined);
|
|
119
121
|
};
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
+
const resolverStartedAt = performance.now();
|
|
123
|
+
this.#observeWait?.(sourceKey, "process", { process: "P4", phase: "begin", fileIndex,
|
|
124
|
+
revision, zones: file.sourceZones.length, startedAt: resolverStartedAt });
|
|
125
|
+
let resolved;
|
|
126
|
+
let resolverOutcome = "ok";
|
|
127
|
+
try {
|
|
128
|
+
resolved = await this.#resolvePlayback({ sourceKey, fileIndex, durationSeconds, zones: file.sourceZones,
|
|
129
|
+
isCurrent, workingOut });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
resolverOutcome = "error";
|
|
132
|
+
throw error;
|
|
133
|
+
} finally {
|
|
134
|
+
const resolverEndedAt = performance.now();
|
|
135
|
+
this.#observeWait?.(sourceKey, "process", { process: "P4", phase: "end", fileIndex, revision,
|
|
136
|
+
startedAt: resolverStartedAt, endedAt: resolverEndedAt, durationMs: resolverEndedAt - resolverStartedAt,
|
|
137
|
+
outcome: resolverOutcome, resolvedZones: Array.isArray(resolved) ? resolved.length : null });
|
|
138
|
+
}
|
|
122
139
|
if (revision !== file.revision) return;
|
|
123
140
|
this.#setZones(file, resolved);
|
|
124
141
|
return this.#emit(file);
|
package/utils/wait-trace.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** Bounded, opt-in startup observations. No torrent or application imports. */
|
|
2
|
+
const SOURCE_CONTEXT_TTL_MS = 30 * 60_000;
|
|
3
|
+
|
|
2
4
|
export class SourceWaitTrace {
|
|
3
5
|
constructor({ emit, now = () => performance.now() }) {
|
|
4
6
|
Object.assign(this, { emit, now, sources: new Map(), requests: new WeakMap() });
|
|
@@ -9,16 +11,34 @@ export class SourceWaitTrace {
|
|
|
9
11
|
operation: context.operation, event, at: this.now(), ...fields });
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
process(sourceKey, process, phase, fields = {}) {
|
|
15
|
+
const context = this.sources.get(sourceKey);
|
|
16
|
+
if (!context || this.now() - context.seen > SOURCE_CONTEXT_TTL_MS) return;
|
|
17
|
+
this.write({ ...context, operation: null }, "process", { sourceKey, process, phase, ...fields });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
has(sourceKey) {
|
|
21
|
+
const context = this.sources.get(sourceKey);
|
|
22
|
+
return Boolean(context && this.now() - context.seen <= SOURCE_CONTEXT_TTL_MS);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
associate(request, sourceKey) {
|
|
26
|
+
const context = this.requests.get(request);
|
|
27
|
+
if (!context || typeof sourceKey !== "string" || !sourceKey) return false;
|
|
28
|
+
this.remember(sourceKey, context);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
12
32
|
remember(sourceKey, context) {
|
|
13
33
|
if (typeof sourceKey !== "string" || !sourceKey) return;
|
|
14
|
-
for (const [key, value] of this.sources) if (this.now() - value.seen >
|
|
34
|
+
for (const [key, value] of this.sources) if (this.now() - value.seen > SOURCE_CONTEXT_TTL_MS) this.sources.delete(key);
|
|
15
35
|
if (this.sources.size >= 64 && !this.sources.has(sourceKey)) this.sources.delete(this.sources.keys().next().value);
|
|
16
36
|
this.sources.set(sourceKey, { ...context, seen: this.now() });
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
source(sourceKey, event, fields = {}) {
|
|
20
40
|
const context = this.sources.get(sourceKey);
|
|
21
|
-
if (!context || this.now() - context.seen >
|
|
41
|
+
if (!context || this.now() - context.seen > SOURCE_CONTEXT_TTL_MS) return;
|
|
22
42
|
this.write({ ...context, operation: null }, event, { sourceKey, ...fields });
|
|
23
43
|
}
|
|
24
44
|
|
|
@@ -26,19 +46,23 @@ export class SourceWaitTrace {
|
|
|
26
46
|
app.addHook("onRequest", async (req, reply) => {
|
|
27
47
|
const wait = req.headers["x-ttv-wait-trace"];
|
|
28
48
|
const operation = req.headers["x-ttv-wait-operation"];
|
|
49
|
+
const statedProcess = req.headers["x-ttv-wait-process"];
|
|
29
50
|
const attempt = Number(req.headers["x-ttv-wait-attempt"]);
|
|
30
51
|
if (typeof wait !== "string" || !/^[a-zA-Z0-9.-]{1,80}$/.test(wait) ||
|
|
31
52
|
typeof operation !== "string" || !/^b[0-9]{1,12}$/.test(operation) ||
|
|
32
53
|
!Number.isSafeInteger(attempt) || attempt < 0) return;
|
|
33
54
|
const context = { wait, attempt, operation, startedAt: this.now() };
|
|
34
55
|
this.requests.set(req, context);
|
|
35
|
-
|
|
56
|
+
const process = typeof statedProcess === "string" && /^[A-Z][0-9]+(?:\.(?:[0-9]+[a-z]?|[va]))?$/.test(statedProcess)
|
|
57
|
+
? statedProcess : processForRequest(req.url);
|
|
58
|
+
this.write(context, "http-start", { process, phase: "begin", method: req.method, path: req.url,
|
|
36
59
|
requestId: req.headers["x-ttv-forwarded-request-id"] ?? req.id });
|
|
37
60
|
let finished = false;
|
|
38
61
|
const end = outcome => {
|
|
39
62
|
if (finished) return;
|
|
40
63
|
finished = true;
|
|
41
|
-
this.write(context, "http-end", { outcome, status: reply.statusCode,
|
|
64
|
+
this.write(context, "http-end", { process, phase: "end", outcome, status: reply.statusCode,
|
|
65
|
+
startedAt: context.startedAt, endedAt: this.now(), duration: this.now() - context.startedAt });
|
|
42
66
|
};
|
|
43
67
|
reply.raw.once("finish", () => end("finished"));
|
|
44
68
|
reply.raw.once("close", () => end(reply.raw.writableFinished ? "finished" : "closed"));
|
|
@@ -66,3 +90,13 @@ export class SourceWaitTrace {
|
|
|
66
90
|
});
|
|
67
91
|
}
|
|
68
92
|
}
|
|
93
|
+
|
|
94
|
+
function processForRequest(path) {
|
|
95
|
+
if (path.startsWith("/api/transcode-sessions")) return "P6.v";
|
|
96
|
+
if (path.startsWith("/api/playback-plan")) return "P3";
|
|
97
|
+
if (/\/transcode\/.*\.m3u8(?:\?|$)/.test(path)) return "P12";
|
|
98
|
+
if (/\/transcode\/.*(?:init|\.mp4)(?:\?|$)/.test(path)) return "P12";
|
|
99
|
+
if (/\/transcode\/.*(?:\.m4s|\.ts)(?:\?|$)/.test(path)) return "P12";
|
|
100
|
+
if (path.startsWith("/api/session-progress")) return "P14";
|
|
101
|
+
return null;
|
|
102
|
+
}
|