@torrent-tv/proxy 2.101.43 → 2.102.1

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/bin/cli.js CHANGED
@@ -406,7 +406,8 @@ try {
406
406
  // Late-bound for the same reason: what the connections carry beyond the
407
407
  // film is counted by the data-channel handler, built further down.
408
408
  serviceShare: () => dataChannelHandler?.serviceShare?.() ?? null,
409
- onStateChanged: () => void stateReporter.changed()
409
+ onStateChanged: () => void stateReporter.changed(),
410
+ onChaptersChanged: (event) => dataChannelHandler?.publishChaptersChanged(event)
410
411
  });
411
412
  app = started.app;
412
413
  actualPort = started.port;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.43",
3
+ "version": "2.102.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -0,0 +1,8 @@
1
+ /** Snapshot only; chapter discovery never delays playback preparation. */
2
+ export function handleApiSourceChaptersGet(req, reply, { chapterController }) {
3
+ const sourceKey = String(req.params?.sourceKey ?? "");
4
+ const rawIndex = req.query?.fileIndex;
5
+ const fileIndex = typeof rawIndex === "string" && /^\d+$/.test(rawIndex) ? Number(rawIndex) : NaN;
6
+ const result = chapterController.get({ sourceKey, fileIndex });
7
+ return reply.header("Cache-Control", "no-store").code(result.status).send(result.body);
8
+ }
package/server.js CHANGED
@@ -12,6 +12,7 @@ import fastifyHelmet from "@fastify/helmet";
12
12
  import getPort from "get-port";
13
13
  import { createRequire } from "node:module";
14
14
  import { performance } from "node:perf_hooks";
15
+ import { SourceWaitTrace } from "./utils/wait-trace.js";
15
16
  import { handleHealthGet } from "./routes/health/get.js";
16
17
  import { handleMediaGet } from "./routes/media/get.js";
17
18
  import { ProbeRequests } from "./services/media/ProbeRequests.js";
@@ -31,6 +32,9 @@ import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
31
32
  import { handleApiSourceFingerprintGet } from "./routes/api/sources/fingerprint/get.js";
32
33
  import { handleApiSourceContainerMetadataGet } from "./routes/api/sources/container-metadata/get.js";
33
34
  import { handleApiSourceCoverGet } from "./routes/api/sources/cover/get.js";
35
+ import { handleApiSourceChaptersGet } from "./routes/api/sources/chapters/get.js";
36
+ import { ChapterOrchestrator } from "./services/media/ChapterOrchestrator.js";
37
+ import { ChapterController } from "./services/server/controllers/ChapterController.js";
34
38
  import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
35
39
  import { handleApiSourceViewerPost } from "./routes/api/sources/viewer/post.js";
36
40
  import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
@@ -127,7 +131,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
127
131
  * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
128
132
  */
129
133
  export async function startProxyServer({
130
- host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
134
+ host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, onChaptersChanged, diagnostics, budgetPolicy,
131
135
  deliverySink = false,
132
136
  // Whether a memory high-water earns a heap snapshot (`--heap-snapshots`).
133
137
  heapSnapshots = false,
@@ -154,6 +158,8 @@ export async function startProxyServer({
154
158
  bodyLimit: 256 * 1024 * 1024 // 256 MB
155
159
  });
156
160
  const forwardedSessionResponseStarts = new WeakMap();
161
+ const waitTrace = new SourceWaitTrace({ emit: value => logger.info(`[wait-trace] ${JSON.stringify(value)}`) });
162
+ waitTrace.install(app);
157
163
  const logForwardedSessionResponseStage = (stage, req) => {
158
164
  const startedAt = forwardedSessionResponseStarts.get(req);
159
165
  const requestId = req.headers["x-ttv-forwarded-request-id"];
@@ -172,7 +178,7 @@ export async function startProxyServer({
172
178
  await app.register(fastifyCors, {
173
179
  origin: true,
174
180
  methods: ["GET", "POST", "OPTIONS"],
175
- allowedHeaders: ["Content-Type", "Range"]
181
+ allowedHeaders: ["Content-Type", "Range", "X-Ttv-Wait-Trace", "X-Ttv-Wait-Attempt", "X-Ttv-Wait-Operation"]
176
182
  });
177
183
 
178
184
  // Allow browser requests from an HTTPS page to this private-network proxy
@@ -217,6 +223,7 @@ export async function startProxyServer({
217
223
  // 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
218
224
  // adapter keeps TorrentPool's interface, so nothing downstream changed.
219
225
  const torrentPool = new WorkerTorrentPool({
226
+ onObserved: fields => waitTrace.source(fields.sourceKey, "torrent-ready", fields),
220
227
  memoryBytes,
221
228
  stateDir,
222
229
  heapSnapshots,
@@ -224,6 +231,7 @@ export async function startProxyServer({
224
231
  onSourceForgotten: ({ sourceKey }) => {
225
232
  sourcePreparation?.forget(sourceKey);
226
233
  mediaReads.forget(sourceKey);
234
+ chapters.forget(sourceKey);
227
235
  probeReads.forget(sourceKey);
228
236
  subtitles.forget(sourceKey);
229
237
  keyframeTables.forget(sourceKey);
@@ -246,6 +254,8 @@ export async function startProxyServer({
246
254
  // only once the worker thread is up and a piece has verified, which is long
247
255
  // after the declarations below have run.
248
256
  onPiecesArrived: ({ sourceKey, fileIndexes, arrived }) => {
257
+ waitTrace.source(sourceKey, "held-bytes-changed", { fileIndexes, arrived: arrived?.slice?.(0, 32),
258
+ arrivedCount: arrived?.length });
249
259
  for (const fileIndex of fileIndexes) {
250
260
  mediaReads.bytesChanged(sourceKey, fileIndex);
251
261
  probeReads.bytesChanged(sourceKey, fileIndex);
@@ -382,6 +392,24 @@ export async function startProxyServer({
382
392
  * @param {object} [known] - The torrent handle when the caller already has it.
383
393
  * @returns {Promise<import("./services/media/SubtitleCues.js").HeldFile | null>}
384
394
  */
395
+ const chapters = new ChapterOrchestrator({
396
+ containers: containerOrchestrator,
397
+ subscribe: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener),
398
+ withdraw: (sourceKey, fileIndex) => {
399
+ mediaReads.retain(sourceKey, fileIndex, (_params, statement) => statement !== "chapters");
400
+ void downloadMaps.withdrawMetadata(sourceKey, fileIndex, "chapters").catch(error => logger.warn(`chapter demand withdrawal failed: ${error.message}`));
401
+ },
402
+ failed: (error) => logger.warn(`chapter request failed: ${error.message}`),
403
+ onChanged: (event) => onChaptersChanged?.(event),
404
+ fileFor: async (sourceKey, fileIndex) => {
405
+ if (!sourceRegistry.get(sourceKey)) return null;
406
+ const torrent = torrentPool.knownTorrent(sourceKey);
407
+ const file = torrent?.files?.[fileIndex];
408
+ if (!file) return torrent ? { invalid: true } : null;
409
+ return containerOver({ sourceKey, fileIndex });
410
+ }
411
+ });
412
+ const chapterController = new ChapterController({ sourceRegistry, chapters });
385
413
  const heldFileFor = async (sourceKey, fileIndex, known = null) => {
386
414
  let torrent = known;
387
415
  if (!torrent) {
@@ -849,6 +877,7 @@ export async function startProxyServer({
849
877
  } }),
850
878
  label: String(file.name ?? ""),
851
879
  fileOffset: file.offset,
880
+ onObserved: fields => waitTrace.source(sourceKey, "container-read", { fileIndex, requestId, ...fields }),
852
881
  onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
853
882
  onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
854
883
  onReadResult: async (statement, result, revision) => {
@@ -859,7 +888,9 @@ export async function startProxyServer({
859
888
  format: containerOrchestrator.known(sourceKey, fileIndex)?.formatName ?? "unrecognised" });
860
889
  }
861
890
  const finished = mediaReads.record(params, statement, result, revision.storage, revision.memory);
862
- await downloadMaps.metadata({ sourceKey, fileIndex, statement: requestId ? `${statement}:${requestId}` : statement, result,
891
+ if (statement === "chapters" && result.passive) await downloadMaps.withdrawMetadata(sourceKey, fileIndex, statement);
892
+ else await downloadMaps.metadata({ sourceKey, fileIndex, statement: requestId ? `${statement}:${requestId}` : statement, result,
893
+ ...(statement === "chapters" ? { priority: 1, urgent: false, deadlineAt: null } : {}),
863
894
  ...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}) });
864
895
  if (finished && result.kind === "result") {
865
896
  void downloadMaps.refresh(sourceKey, fileIndex).catch(error =>
@@ -894,6 +925,7 @@ export async function startProxyServer({
894
925
  });
895
926
  let sourcePreparation;
896
927
  const outputParts = wireOutputs({
928
+ observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
897
929
  pinnedPreset,
898
930
  indexMemory,
899
931
  serviceShare,
@@ -1429,6 +1461,9 @@ export async function startProxyServer({
1429
1461
  probeReads.needs(requestId, start, end);
1430
1462
  }
1431
1463
  }));
1464
+ app.get("/api/sources/:sourceKey/chapters", async (req, reply) =>
1465
+ handleApiSourceChaptersGet(req, reply, { chapterController })
1466
+ );
1432
1467
  app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
1433
1468
  // Off unless --delivery-sink was given; see the route for why it exists.
1434
1469
  app.get("/api/delivery-sink", async (req, reply) =>
@@ -1106,6 +1106,7 @@ export class EncodeRuns {
1106
1106
  // proven, and it is cleared up when the run ends.
1107
1107
  // Told when this run closes its first piece; see the next stretch below.
1108
1108
  let atFirstPiece = null;
1109
+ const observedStart = performance.now();
1109
1110
  const run = new EncodeRun({
1110
1111
  address: session.outputKey ?? session.id,
1111
1112
  encoder: this.#host.videoEncoder,
@@ -1196,6 +1197,10 @@ export class EncodeRuns {
1196
1197
  { code: "ERR_INCOMPLETE_MEDIA" });
1197
1198
  const published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
1198
1199
  if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
1200
+ if (index === safeIndex) this.#host.observeWait?.(session.file.sourceKey, "first-piece-published", {
1201
+ output: address, segment: index, startedAt: observedStart, endedAt: performance.now(),
1202
+ encoderWrittenAfterMs: Number.isFinite(writtenAt) ? writtenAt - run.startedAt : null,
1203
+ namedAfterMs: Number.isFinite(namedAt) ? namedAt - run.startedAt : null });
1199
1204
  this.#leadingByOutput.get(address)?.delete(index);
1200
1205
  // WHERE A LATE PIECE'S TIME WENT: the encoder writing it, this process
1201
1206
  // getting to its name, or its publication waiting for the file after
@@ -0,0 +1,104 @@
1
+ /** Selected-file snapshots; retries belong to the shared media request queue. */
2
+ import { chapterResult } from "./container/chapters.js";
3
+ import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ export class ChapterOrchestrator {
7
+ constructor({ containers, fileFor, subscribe = () => () => {}, withdraw = () => {}, now = Date.now, onChanged = () => {}, failed = () => {} }) {
8
+ Object.assign(this, { containers, fileFor, subscribe, withdraw, now, onChanged, failed });
9
+ this.files = new Map();
10
+ }
11
+
12
+ get(sourceKey, fileIndex) {
13
+ for (const old of this.files.values()) if (old.expires < this.now()) this.forget(old.sourceKey, old.fileIndex);
14
+ const key = `${sourceKey}:${fileIndex}`;
15
+ let entry = this.files.get(key);
16
+ if (!entry) {
17
+ entry = { sourceKey, fileIndex, generation: randomUUID(), reading: null, requested: false, bytes: 0 };
18
+ entry.dispose = () => {
19
+ entry.allocation?.release(entry.bytes + (entry.candidate?.length ?? 0));
20
+ entry.allocation?.dispose();
21
+ entry.bytes = 0;
22
+ entry.candidate = null;
23
+ entry.buffer = null;
24
+ };
25
+ this.files.set(key, entry);
26
+ entry.unsubscribe = this.subscribe(sourceKey, fileIndex, (event) => {
27
+ if (event.kind === "cancelled") this.forget(sourceKey, fileIndex);
28
+ else if (!entry.requested && event.kind === "bytes-changed") this.warm(entry);
29
+ });
30
+ }
31
+ entry.expires = this.now() + 3600000;
32
+ if (!entry.requested) this.warm(entry);
33
+ return { ...(entry.buffer ? JSON.parse(entry.buffer.toString("utf8")) : chapterResult("pending")), sourceKey, fileIndex,
34
+ generation: entry.generation, revision: entry.revision ?? 0 };
35
+ }
36
+
37
+ update(entry, result) {
38
+ if (this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) !== entry) return;
39
+ let answer;
40
+ if (result.value?.version === 1) answer = result.value;
41
+ else if (result.kind === "needs-ranges") answer = chapterResult("pending", { missingRange: result.ranges[0] });
42
+ else if (result.kind === "needs-memory") answer = chapterResult("pending", { resource: "memory", requiredBytes: result.bytes });
43
+ else if (result.reason === "request-obsolete") return;
44
+ else if (["format-not-supported", "chapter-not-supported"].includes(result.reason)) {
45
+ answer = chapterResult("unsupported", { reason: result.chapterReason ?? result.reason });
46
+ } else answer = chapterResult("error", { reason: result.message ?? result.reason });
47
+ const buffer = result.value && entry.candidate ? entry.candidate : Buffer.from(JSON.stringify(answer));
48
+ const charged = buffer === entry.candidate ? buffer.length : 0;
49
+ if (entry.candidate && !charged) entry.allocation?.release(entry.candidate.length);
50
+ entry.candidate = null;
51
+ if (entry.buffer?.equals(buffer)) {
52
+ entry.allocation?.release(charged);
53
+ return;
54
+ }
55
+ entry.allocation?.release(entry.bytes);
56
+ entry.bytes = charged;
57
+ entry.buffer = buffer;
58
+ entry.revision = (entry.revision ?? 0) + 1;
59
+ this.onChanged({ sourceKey: entry.sourceKey, fileIndex: entry.fileIndex, revision: entry.revision });
60
+ }
61
+
62
+ warm(entry) {
63
+ if (entry.reading) return;
64
+ entry.reading = (async () => {
65
+ const params = await this.fileFor(entry.sourceKey, entry.fileIndex);
66
+ if (!params || this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) !== entry) return;
67
+ entry.requested = true;
68
+ if (params.invalid) return this.update(entry, { kind: "terminal", reason: "file-index-not-found" });
69
+ const publish = params.onReadResult;
70
+ entry.allocation ??= params.packetMemory?.forRecord?.(entry, "declarations");
71
+ params.acceptChapterResult = (value) => {
72
+ const serialized = JSON.stringify(value);
73
+ const bytes = Buffer.byteLength(serialized);
74
+ if (entry.allocation?.reserve(bytes) === false) throw new IndexMemoryUnavailable(bytes);
75
+ entry.candidate = Buffer.from(serialized);
76
+ };
77
+ params.isCurrent = () => {
78
+ if (entry.expires <= this.now()) this.forget(entry.sourceKey, entry.fileIndex);
79
+ return this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) === entry;
80
+ };
81
+ params.onReadResult = async (statement, result, revision) => {
82
+ await publish?.(statement, result, revision);
83
+ this.update(entry, result);
84
+ };
85
+ await this.containers.inspect(params, "chapters");
86
+ })().catch((error) => {
87
+ this.failed(error);
88
+ if (this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) === entry) {
89
+ entry.requested = false;
90
+ this.update(entry, { kind: "terminal", reason: "chapter-request-failed", message: error.message });
91
+ }
92
+ }).finally(() => { entry.reading = null; });
93
+ }
94
+
95
+ forget(sourceKey, fileIndex) {
96
+ for (const [key, entry] of this.files) {
97
+ if (entry.sourceKey !== sourceKey || (fileIndex !== undefined && entry.fileIndex !== fileIndex)) continue;
98
+ this.files.delete(key);
99
+ entry.dispose();
100
+ entry.unsubscribe?.();
101
+ this.withdraw(entry.sourceKey, entry.fileIndex);
102
+ }
103
+ }
104
+ }
@@ -20,6 +20,7 @@ import { BytesUnavailable, isUnavailable, strictReader } from "./container/unava
20
20
  import { OSHASH_EDGE_BYTES, hasOshash, oshash } from "./oshash.js";
21
21
  import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
22
22
  import { OutsideReadableEdges } from "./container/work-tags.js";
23
+ import { ChapterUnsupported } from "./container/chapters.js";
23
24
  import { logger } from "../../utils/logger.js";
24
25
 
25
26
  /**
@@ -139,7 +140,7 @@ export class ContainerOrchestrator {
139
140
 
140
141
  /** Read one statement without converting missing bytes into an empty answer. */
141
142
  async inspect(params, statement = "tracks") {
142
- if (!["container", "tracks", "media-info", "keyframes", "packets", "source-navigation", "source-ranges", "fingerprint", "work-tags", "cover"].includes(statement)) {
143
+ if (!["container", "tracks", "media-info", "keyframes", "packets", "source-navigation", "source-ranges", "fingerprint", "work-tags", "cover", "chapters"].includes(statement)) {
143
144
  throw new TypeError(`Unknown media statement: ${statement}`);
144
145
  }
145
146
  const key = `${params.sourceKey}:${params.fileIndex}`;
@@ -155,13 +156,17 @@ export class ContainerOrchestrator {
155
156
  // table of the whole film it does not use (r38, torrent-tv/meta#178).
156
157
  const aside = this.#answersWithoutReading(key, statement);
157
158
  const previous = aside ? Promise.resolve() : this.#reads.get(key) ?? Promise.resolve();
158
- const askedAt = Date.now();
159
+ const askedAt = performance.now();
159
160
  const reading = previous.catch(() => undefined).then(async () => {
160
161
  if (!isCurrent()) return { result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } };
161
- const startedAt = Date.now();
162
+ const startedAt = performance.now();
162
163
  const revision = params.onReadStart?.(statement);
163
164
  const result = await this.#inspectRead({ ...params, isCurrent }, statement);
164
- params.onTimed?.(statement, startedAt - askedAt, Date.now() - startedAt);
165
+ const endedAt = performance.now();
166
+ params.onTimed?.(statement, startedAt - askedAt, endedAt - startedAt);
167
+ if (statement !== "source-ranges" || endedAt - askedAt >= 10) params.onObserved?.({ statement, askedAt,
168
+ startedAt, endedAt, queuedMs: startedAt - askedAt, readMs: endedAt - startedAt, cached: aside,
169
+ result: result.kind, reason: result.reason, ranges: result.ranges });
165
170
  return { result, revision };
166
171
  });
167
172
  if (!aside) this.#reads.set(key, reading);
@@ -232,11 +237,20 @@ export class ContainerOrchestrator {
232
237
  value = await container.readPacketIndex(params.packetInterval, { isCurrent: params.isCurrent, account: params.walkAccount });
233
238
  if (value === null) return { kind: "terminal", reason: "request-obsolete", requestId };
234
239
  await value?.prepareAudioDependencies?.(params.packetInterval, container.readRange);
240
+ } else if (statement === "chapters") {
241
+ value = await container.readChapters({ read: params.readHeld ?? params.readRange,
242
+ isCurrent: params.isCurrent,
243
+ ranges: params.heldRanges ? await params.heldRanges() : undefined });
244
+ if (params.isCurrent?.() === false) return { kind: "terminal", reason: "request-obsolete", requestId };
245
+ params.acceptChapterResult?.(value);
246
+ if (!value.complete && value.pending) return { kind: "needs-ranges", ranges: [[value.pending.start, value.pending.end]], value, requestId };
235
247
  } else {
236
248
  throw new TypeError(`Unknown media statement: ${statement}`);
237
249
  }
238
250
  return { kind: "result", value, requestId };
239
251
  } catch (error) {
252
+ if (statement === "chapters" && params.isCurrent?.() === false) return { kind: "terminal", reason: "request-obsolete", requestId };
253
+ if (error instanceof ChapterUnsupported) return { kind: "terminal", reason: "chapter-not-supported", chapterReason: error.message, requestId };
240
254
  if (error instanceof IndexMemoryUnavailable) {
241
255
  const result = { kind: "needs-memory", bytes: error.bytes, requestId };
242
256
  logger.info(`media request=${requestId} needs memory=${error.bytes}`);
@@ -248,7 +262,8 @@ export class ContainerOrchestrator {
248
262
  return { kind: "terminal", reason: "outside-edges", requestId };
249
263
  }
250
264
  if (isUnavailable(error)) {
251
- const result = { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId };
265
+ const result = { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId,
266
+ ...(error.passive ? { passive: true } : {}) };
252
267
  logger.info(`media request=${requestId} needs bytes=${error.start}-${error.end}`);
253
268
  return result;
254
269
  }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { isUnavailable, strictReader } from "./unavailable.js";
16
16
  import { textList } from "./work-tags.js";
17
+ import { chapterResult } from "./chapters.js";
17
18
 
18
19
  /**
19
20
  * What one file declares about itself. Every field is either a value the
@@ -29,6 +30,10 @@ import { textList } from "./work-tags.js";
29
30
  */
30
31
 
31
32
  export class Container {
33
+ /** Read chapters only through the caller's non-fetching held-byte reader. */
34
+ async readChapters(_held) {
35
+ return chapterResult("unsupported", { reason: `chapter-container:${this.formatName}` });
36
+ }
32
37
  /**
33
38
  * One strict available-byte reader and one container per
34
39
  * file — the one `ContainerOrchestrator` keeps.
@@ -34,6 +34,7 @@
34
34
 
35
35
  import { FFMPEG_STREAM_SEARCH_SECONDS } from "./ffmpeg-stream-search.js";
36
36
  import { Container } from "./Container.js";
37
+ import { readMatroskaChapters } from "./matroska-chapters.js";
37
38
  import { VideoTrack } from "../tracks/VideoTrack.js";
38
39
  import { h264Configuration } from "./h264-configuration.js";
39
40
  import { hevcConfiguration } from "./hevc-configuration.js";
@@ -144,6 +145,9 @@ function readString(buf, el) {
144
145
  */
145
146
 
146
147
  export class MatroskaContainer extends Container {
148
+ async readChapters(held) {
149
+ return readMatroskaChapters(this, held);
150
+ }
147
151
  /** @type {Buffer | null} */
148
152
  #declarations = null;
149
153
 
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { Container } from "./Container.js";
20
20
  import { RetainedBytes } from "./RetainedBytes.js";
21
+ import { readMp4Chapters } from "./mp4-chapters.js";
21
22
  import { VideoTrack } from "../tracks/VideoTrack.js";
22
23
  import { AudioTrack } from "../tracks/AudioTrack.js";
23
24
  import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
@@ -265,6 +266,9 @@ function decoderSpecificInfoOf(buf, start, end) {
265
266
 
266
267
  export class Mp4Container extends Container {
267
268
  #tracks = null;
269
+ async readChapters(held) {
270
+ return readMp4Chapters(this, held);
271
+ }
268
272
  get formatName() {
269
273
  return "mp4";
270
274
  }
@@ -0,0 +1,104 @@
1
+ /** File chapter declarations and bounded, non-fetching byte access. */
2
+ import { BytesUnavailable, strictReader } from "./unavailable.js";
3
+
4
+ export class ChapterUnsupported extends Error {
5
+ constructor(reason) { super(reason); this.name = "ChapterUnsupported"; }
6
+ }
7
+
8
+ export function chapterResult(state, extra = {}) {
9
+ return { version: 1, state, complete: state !== "pending", selectedEditionId: null,
10
+ editions: [], sections: [], tailContent: "unknown", ...extra };
11
+ }
12
+
13
+ /** Limits bound hostile metadata, not the media file's size. */
14
+ export const CHAPTER_LIMITS = { entries: 4096, depth: 64, textBytes: 8192, readBytes: 8 * 1024 * 1024, elements: 100000 };
15
+
16
+ export class ChapterBytes {
17
+ constructor(held, size) {
18
+ this.held = held;
19
+ this.size = size;
20
+ this.read = strictReader(held.read, size);
21
+ this.used = 0;
22
+ this.elements = 0;
23
+ this.window = null;
24
+ this.windowAt = 0;
25
+ }
26
+
27
+ async bytes(start, length) {
28
+ if (this.held.isCurrent?.() === false) throw new Error("Chapter request was withdrawn");
29
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(length) || start < 0 || length < 0 || start + length > this.size) {
30
+ throw new Error("Chapter byte range is outside the file");
31
+ }
32
+ if (length === 0) return Buffer.alloc(0);
33
+ if (this.window && start >= this.windowAt && start + length <= this.windowAt + this.window.length) {
34
+ return this.window.subarray(start - this.windowAt, start - this.windowAt + length);
35
+ }
36
+ const range = this.held.ranges?.find(([first, last]) => first <= start && last >= start + length - 1);
37
+ if (this.held.ranges && !range) {
38
+ const error = new BytesUnavailable(start, start + length - 1, 0);
39
+ error.passive = this.passiveMissing === true;
40
+ throw error;
41
+ }
42
+ // Header traversal must not copy adjacent video payloads into the metadata budget.
43
+ const span = length;
44
+ this.used += span;
45
+ if (this.used > CHAPTER_LIMITS.readBytes) throw new ChapterUnsupported("chapter-read-budget");
46
+ if (++this.elements % 64 === 0) await new Promise((resolve) => setImmediate(resolve));
47
+ try { this.window = await this.read(start, start + span - 1); }
48
+ catch (error) {
49
+ if (error?.name === "BytesUnavailable") error.passive = this.passiveMissing === true;
50
+ throw error;
51
+ }
52
+ this.windowAt = start;
53
+ return this.window.subarray(0, length);
54
+ }
55
+
56
+ step(depth = 0) {
57
+ if (this.held.isCurrent?.() === false) throw new Error("Chapter request was withdrawn");
58
+ if (++this.elements > CHAPTER_LIMITS.elements || depth > CHAPTER_LIMITS.depth) {
59
+ throw new ChapterUnsupported("chapter-structure-budget");
60
+ }
61
+ }
62
+ }
63
+
64
+ export function chapterSeconds(value, units) {
65
+ const seconds = Number(value / BigInt(units)) + Number(value % BigInt(units)) / units;
66
+ if (!Number.isFinite(seconds) || seconds > Number.MAX_SAFE_INTEGER) throw new Error("Chapter time is outside the supported numeric range");
67
+ return seconds;
68
+ }
69
+
70
+ export function classifyChapter(titles) {
71
+ const types = new Set();
72
+ for (const { text } of titles) {
73
+ const title = text.trim().toLowerCase();
74
+ if (/^(op(?:ening)?|opening credits|opening theme|オープニング)$/.test(title)) types.add("opening");
75
+ if (/^(ed|ending|ending theme|エンディング)$/.test(title)) types.add("ending");
76
+ if (/^(credits|end credits|closing credits|титры)$/.test(title)) types.add("credits");
77
+ if (/^(post[- ]credits?(?: scene)?|after[- ]credits?(?: scene)?)$/.test(title)) types.add("post-credits");
78
+ }
79
+ const inferred = types.size === 1;
80
+ return { type: inferred ? [...types][0] : "chapter",
81
+ classification: inferred ? "title-hint" : "unclassified",
82
+ confidence: inferred ? "unconfirmed" : "declared" };
83
+ }
84
+
85
+ /** Build navigation intervals without promoting inferred ends to file facts. */
86
+ export function navigationSections(edition, duration = null) {
87
+ const out = [];
88
+ const walk = (chapters) => {
89
+ for (const chapter of chapters) {
90
+ if (chapter.enabled && !chapter.hidden) {
91
+ const next = chapters.filter((other) => other.startSeconds > chapter.startSeconds)
92
+ .reduce((end, other) => Math.min(end, other.startSeconds), Infinity);
93
+ const inferred = Number.isFinite(next) ? next : duration;
94
+ out.push({ ...chapter, children: undefined, ...classifyChapter(chapter.titles),
95
+ displayEndSeconds: chapter.endSeconds ?? inferred,
96
+ endSource: chapter.endSeconds !== null ? "declared" : inferred !== null ? "navigation" : "unknown",
97
+ automaticSkipAllowed: false, evidence: "file-chapter" });
98
+ }
99
+ walk(chapter.children);
100
+ }
101
+ };
102
+ walk(edition.chapters);
103
+ return out.sort((a, b) => a.startSeconds - b.startSeconds || a.order - b.order);
104
+ }
@@ -0,0 +1,213 @@
1
+ /** RFC 9559 §§5.1.7, 6.6, 11.1.1 and 20: chapter metadata only. */
2
+ import { readVint } from "./ebml-reader.js";
3
+ import { ChapterBytes, ChapterUnsupported, CHAPTER_LIMITS, chapterResult, chapterSeconds, navigationSections } from "./chapters.js";
4
+
5
+ const SEGMENT = 0x18538067;
6
+ const CHAPTERS = 0x1043a770;
7
+ const SEEK_HEAD = 0x114d9b74;
8
+ const CLUSTER = 0x1f43b675;
9
+ const TOP_LEVEL = new Set([SEEK_HEAD, CHAPTERS, CLUSTER, 0x1549a966, 0x1654ae6b, 0x1c53bb6b, 0x1941a469, 0x1254c367]);
10
+
11
+ async function header(reader, at, limit) {
12
+ reader.step();
13
+ const vint = async (position, marker) => {
14
+ const first = (await reader.bytes(position, 1))[0];
15
+ let n = 1;
16
+ while (n <= 8 && !(first & (0x80 >> (n - 1)))) n++;
17
+ if (n > (marker ? 4 : 8) || position + n > limit) throw new Error("Invalid chapter EBML header");
18
+ const bytes = await reader.bytes(position, n);
19
+ const value = readVint(bytes, 0, marker);
20
+ const mask = 2 ** (8 - n) - 1;
21
+ return { ...value, unknown: !marker && (bytes[0] & mask) === mask && bytes.subarray(1).every((byte) => byte === 255) };
22
+ };
23
+ const id = await vint(at, true);
24
+ const size = await vint(at + id.length, false);
25
+ const dataOffset = at + id.length + size.length;
26
+ const unknown = size.unknown;
27
+ const end = unknown ? null : dataOffset + size.value;
28
+ if (!Number.isSafeInteger(dataOffset) || (!unknown && (!Number.isSafeInteger(end) || end > limit))) throw new Error("Chapter element exceeds its parent");
29
+ return { id: id.value, at, dataOffset, end, size: unknown ? null : size.value };
30
+ }
31
+
32
+ async function* children(reader, master, depth = 0) {
33
+ reader.step(depth);
34
+ if (master.end === null) throw new Error("Unknown size is not valid for this chapter element");
35
+ for (let at = master.dataOffset; at < master.end;) {
36
+ const child = await header(reader, at, master.end);
37
+ if (child.end === null) throw new Error("Unknown-sized chapter child");
38
+ yield child;
39
+ at = child.end;
40
+ }
41
+ }
42
+
43
+ async function integer(reader, field, fallback = null) {
44
+ if (field.size > 8) throw new Error("Chapter integer exceeds eight bytes");
45
+ if (field.size === 0) return fallback;
46
+ const bytes = await reader.bytes(field.dataOffset, field.size);
47
+ let value = 0n;
48
+ for (const byte of bytes) value = value * 256n + BigInt(byte);
49
+ return value;
50
+ }
51
+
52
+ async function text(reader, field) {
53
+ if (field.size > CHAPTER_LIMITS.textBytes) throw new ChapterUnsupported("chapter-title-budget");
54
+ return new TextDecoder("utf-8", { fatal: true }).decode(await reader.bytes(field.dataOffset, field.size)).replace(/\0+$/, "");
55
+ }
56
+
57
+ async function display(reader, field, depth) {
58
+ let value = null;
59
+ const languages = [], bcp47 = [], countries = [];
60
+ for await (const part of children(reader, field, depth)) {
61
+ if (part.id === 0x85) value = await text(reader, part);
62
+ if (part.id === 0x437c) languages.push(await text(reader, part));
63
+ if (part.id === 0x437d) bcp47.push(await text(reader, part));
64
+ if (part.id === 0x437e) countries.push(await text(reader, part));
65
+ }
66
+ if (value === null) throw new Error("ChapterDisplay has no ChapString");
67
+ return { text: value, languages: bcp47.length ? bcp47 : languages.length ? languages : ["eng"],
68
+ countries: bcp47.length ? [] : countries, languageSource: bcp47.length ? "bcp47" : "matroska" };
69
+ }
70
+
71
+ async function atom(reader, field, editionId, parentId, count, depth) {
72
+ if (++count.value > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-count-budget");
73
+ let uid = null, start = null, end = null, linkedSegment = null, linkedEdition = null;
74
+ let hidden = false, enabled = true;
75
+ const titles = [], childFields = [], trackNumbers = [], processCodecs = [];
76
+ for await (const part of children(reader, field, depth)) {
77
+ if (part.id === 0x73c4) uid = await integer(reader, part);
78
+ if (part.id === 0x91) start = await integer(reader, part, 0n);
79
+ if (part.id === 0x92) end = await integer(reader, part, 0n);
80
+ if (part.id === 0x98) hidden = (await integer(reader, part, 0n)) !== 0n;
81
+ if (part.id === 0x4598) enabled = (await integer(reader, part, 1n)) !== 0n;
82
+ if (part.id === 0x6e67) {
83
+ if (part.size !== 16) throw new Error("ChapterSegmentUUID must contain sixteen bytes");
84
+ linkedSegment = (await reader.bytes(part.dataOffset, part.size)).toString("hex");
85
+ }
86
+ if (part.id === 0x6ebc) linkedEdition = String(await integer(reader, part));
87
+ if (part.id === 0x80) titles.push(await display(reader, part, depth + 1));
88
+ if (part.id === 0xb6) childFields.push(part);
89
+ if (part.id === 0x8f) {
90
+ for await (const track of children(reader, part, depth + 1)) if (track.id === 0x89) trackNumbers.push(String(await integer(reader, track)));
91
+ }
92
+ if (part.id === 0x6944) {
93
+ let codec = 0n;
94
+ for await (const command of children(reader, part, depth + 1)) if (command.id === 0x6955) codec = await integer(reader, command, 0n);
95
+ processCodecs.push(String(codec));
96
+ }
97
+ }
98
+ if (uid === null || uid === 0n || start === null) throw new Error("ChapterAtom requires a nonzero ChapterUID and ChapterTimeStart");
99
+ if (end !== null && end < start) throw new Error("ChapterTimeEnd precedes ChapterTimeStart");
100
+ const id = `${editionId}:chapter:${uid}`;
101
+ const chapter = { id, uid: String(uid), editionId, parentId, order: count.value,
102
+ startNanoseconds: String(start), endNanoseconds: end === null ? null : String(end),
103
+ startSeconds: chapterSeconds(start, 1e9), endSeconds: end === null ? null : chapterSeconds(end, 1e9),
104
+ hidden, enabled, titles, linkedSegment, linkedEdition, trackNumbers, processCodecs, children: [] };
105
+ for (const child of childFields) {
106
+ const nested = await atom(reader, child, editionId, id, count, depth + 1);
107
+ if (BigInt(nested.startNanoseconds) < start || (end !== null && BigInt(nested.startNanoseconds) > end)) throw new Error("Nested chapter starts outside its parent");
108
+ chapter.children.push(nested);
109
+ }
110
+ return chapter;
111
+ }
112
+
113
+ async function editionsOf(reader, chapters) {
114
+ const editions = [], count = { value: 0 };
115
+ for await (const field of children(reader, chapters)) {
116
+ if (field.id !== 0x45b9) continue;
117
+ let uid = null, hidden = false, isDefault = false, ordered = false;
118
+ const atoms = [];
119
+ for await (const part of children(reader, field, 1)) {
120
+ if (part.id === 0x45bc) uid = await integer(reader, part);
121
+ if (part.id === 0x45bd) hidden = (await integer(reader, part, 0n)) !== 0n;
122
+ if (part.id === 0x45db) isDefault = (await integer(reader, part, 0n)) !== 0n;
123
+ if (part.id === 0x45dd) ordered = (await integer(reader, part, 0n)) !== 0n;
124
+ if (part.id === 0xb6) atoms.push(part);
125
+ }
126
+ const id = uid === null ? `matroska:edition-at:${field.at}` : `matroska:edition:${uid}`;
127
+ const edition = { id, uid: uid === null ? null : String(uid), hidden, isDefault, ordered, chapters: [] };
128
+ for (const part of atoms) edition.chapters.push(await atom(reader, part, id, null, count, 2));
129
+ if (!edition.chapters.length) throw new Error("EditionEntry has no ChapterAtom");
130
+ editions.push(edition);
131
+ }
132
+ if (!editions.length) throw new Error("Chapters has no EditionEntry");
133
+ const ids = new Set();
134
+ const check = (chapters) => {
135
+ for (const chapter of chapters) {
136
+ if (ids.has(chapter.uid)) throw new Error("Duplicate ChapterUID");
137
+ ids.add(chapter.uid); check(chapter.children);
138
+ }
139
+ };
140
+ for (const edition of editions) check(edition.chapters);
141
+ return editions;
142
+ }
143
+
144
+ export async function readMatroskaChapters(container, held) {
145
+ const reader = new ChapterBytes(held, container.fileSize);
146
+ let at = 0, segment = null;
147
+ while (at < reader.size) {
148
+ const field = await header(reader, at, reader.size);
149
+ if (field.id === SEGMENT) { segment = field; break; }
150
+ if (field.end === null) throw new Error("Unknown-sized element before Segment");
151
+ at = field.end;
152
+ }
153
+ if (!segment) throw new Error("Matroska Segment is missing");
154
+ const limit = segment.end ?? reader.size;
155
+ const visits = new Set();
156
+ const seekHeads = [];
157
+ const seek = async (field) => {
158
+ if (visits.has(field.at)) return null;
159
+ visits.add(field.at);
160
+ for await (const entry of children(reader, field)) {
161
+ if (entry.id !== 0x4dbb) continue;
162
+ let id = null, position = null;
163
+ for await (const part of children(reader, entry)) {
164
+ if (part.id === 0x53ab) id = await integer(reader, part);
165
+ if (part.id === 0x53ac) position = await integer(reader, part, 0n);
166
+ }
167
+ if (id === null || position === null) throw new Error("Incomplete chapter Seek entry");
168
+ if (id !== BigInt(CHAPTERS) && id !== BigInt(SEEK_HEAD)) continue;
169
+ const offset = segment.dataOffset + Number(position);
170
+ if (!Number.isSafeInteger(offset) || offset < segment.dataOffset || offset >= limit) throw new Error("Chapter SeekPosition is outside Segment");
171
+ reader.passiveMissing = false;
172
+ const target = await header(reader, offset, limit);
173
+ if (BigInt(target.id) !== id) throw new Error("Chapter SeekID does not match its target");
174
+ if (target.id === CHAPTERS) return target;
175
+ seekHeads.push(target);
176
+ }
177
+ return null;
178
+ };
179
+ let chapters = null;
180
+ let passedCluster = false;
181
+ at = segment.dataOffset;
182
+ while (at < limit && !chapters) {
183
+ // Without a declared location, traversing unavailable media must not
184
+ // download every Cluster just to discover whether chapters follow it.
185
+ reader.passiveMissing = passedCluster;
186
+ const field = await header(reader, at, limit);
187
+ reader.passiveMissing = false;
188
+ if (field.id === CHAPTERS) { chapters = field; break; }
189
+ if (field.id === CLUSTER) passedCluster = true;
190
+ if (field.id === SEEK_HEAD) {
191
+ chapters = await seek(field);
192
+ while (!chapters && seekHeads.length) chapters = await seek(seekHeads.shift());
193
+ if (chapters) break;
194
+ }
195
+ if (field.end !== null) { at = field.end; continue; }
196
+ if (field.id !== CLUSTER) throw new Error("Unknown-sized non-Cluster element inside Segment");
197
+ // An unknown-sized Cluster ends at the next Segment-level element (RFC 8794 §6.2).
198
+ at = field.dataOffset;
199
+ reader.passiveMissing = true;
200
+ while (at < limit) {
201
+ const child = await header(reader, at, limit);
202
+ if (TOP_LEVEL.has(child.id)) break;
203
+ if (child.end === null) throw new ChapterUnsupported("unknown-sized-cluster-child");
204
+ at = child.end;
205
+ }
206
+ }
207
+ if (!chapters) return chapterResult("absent");
208
+ const editions = await editionsOf(reader, chapters);
209
+ const selected = editions.find((edition) => edition.isDefault) ?? editions[0];
210
+ if (selected.ordered) return chapterResult("unsupported", { reason: "ordered-edition-playback", editions, selectedEditionId: selected.id });
211
+ return chapterResult("available", { editions, selectedEditionId: selected.id,
212
+ sections: navigationSections(selected, container.mediaInfo?.durationSeconds ?? null) });
213
+ }
@@ -0,0 +1,310 @@
1
+ /** QuickTime Chapter Lists and ISO/IEC 14496-12 sample tables/edit lists. */
2
+ import { isUnavailable } from "./unavailable.js";
3
+ import { ChapterBytes, ChapterUnsupported, CHAPTER_LIMITS, chapterResult, chapterSeconds, navigationSections } from "./chapters.js";
4
+
5
+ async function box(reader, at, end) {
6
+ reader.step();
7
+ const bytes = await reader.bytes(at, 8);
8
+ let size = bytes.readUInt32BE(0), header = 8;
9
+ if (size === 1) { size = Number((await reader.bytes(at + 8, 8)).readBigUInt64BE()); header = 16; }
10
+ if (size === 0) size = end - at;
11
+ if (!Number.isSafeInteger(size) || size < header || at + size > end) throw new Error("Chapter box exceeds its parent");
12
+ return { type: bytes.toString("latin1", 4, 8), at, dataOffset: at + header, end: at + size };
13
+ }
14
+
15
+ async function boxes(reader, start, end) {
16
+ const result = [];
17
+ for (let at = start; at < end;) {
18
+ if (end - at < 8) throw new Error("Truncated chapter box header");
19
+ const child = await box(reader, at, end);
20
+ result.push(child);
21
+ at = child.end;
22
+ }
23
+ return result;
24
+ }
25
+
26
+ const one = (list, type) => list.find((field) => field.type === type);
27
+ async function inside(reader, parent) { return parent ? boxes(reader, parent.dataOffset, parent.end) : []; }
28
+ async function payload(reader, field, count) {
29
+ if (!field || field.dataOffset + count > field.end) throw new Error("Truncated chapter field");
30
+ return reader.bytes(field.dataOffset, count);
31
+ }
32
+ async function table(reader, field, width, prefix = 8) {
33
+ const head = await payload(reader, field, prefix);
34
+ const count = head.readUInt32BE(prefix - 4);
35
+ if (count > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-sample-table-budget");
36
+ if (field.dataOffset + prefix + width * count !== field.end) throw new Error("Chapter sample table size mismatch");
37
+ return { head, count, bytes: await reader.bytes(field.dataOffset + prefix, count * width) };
38
+ }
39
+
40
+ function titleOf(bytes) {
41
+ if (bytes.length > CHAPTER_LIMITS.textBytes) throw new ChapterUnsupported("chapter-title-budget");
42
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder("utf-16be", { fatal: true }).decode(bytes.subarray(2));
43
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder("utf-16le", { fatal: true }).decode(bytes.subarray(2));
44
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/\0+$/, "");
45
+ }
46
+
47
+ const MAC_LANGUAGES = new Map([[0, "eng"], [1, "fra"], [2, "deu"], [3, "ita"],
48
+ [4, "nld"], [5, "swe"], [6, "spa"], [7, "dan"], [8, "por"], [9, "nor"], [13, "fin"], [32, "rus"]]);
49
+
50
+ function trackTitle(bytes, format, languageCode) {
51
+ if (bytes.length > CHAPTER_LIMITS.textBytes) throw new ChapterUnsupported("chapter-title-budget");
52
+ const bom = bytes.length >= 2 && ((bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0xff && bytes[1] === 0xfe));
53
+ if (format === "tx3g" || bom || (languageCode >= 0x400 && languageCode !== 0x7fff)) return titleOf(bytes);
54
+ if (!MAC_LANGUAGES.has(languageCode)) throw new ChapterUnsupported(`chapter-text-encoding:${languageCode}`);
55
+ return new TextDecoder(languageCode === 32 ? "x-mac-cyrillic" : "macintosh", { fatal: true }).decode(bytes).replace(/\0+$/, "");
56
+ }
57
+
58
+ function chapter(id, start, end, title, language = "und", order = 0) {
59
+ return { id, parentId: null, order, startSeconds: start, endSeconds: end,
60
+ titles: [{ text: title, languages: [language], countries: [], languageSource: "mp4" }], hidden: false, enabled: true, children: [] };
61
+ }
62
+
63
+ async function nero(reader, field) {
64
+ const initial = await payload(reader, field, 4);
65
+ const version = initial[0];
66
+ if (version > 1) throw new ChapterUnsupported("chpl-version");
67
+ const prefix = version === 1 ? 9 : 5;
68
+ const count = (await payload(reader, field, prefix))[prefix - 1];
69
+ let at = field.dataOffset + prefix;
70
+ const chapters = [];
71
+ for (let index = 0; index < count; index++) {
72
+ if (at + 9 > field.end) throw new Error("Truncated chpl entry");
73
+ const head = await reader.bytes(at, 9);
74
+ const time = head.readBigUInt64BE(0), length = head[8];
75
+ at += 9;
76
+ if (at + length > field.end) throw new Error("Truncated chpl title");
77
+ chapters.push({ ...chapter(`mp4:chpl:${index}`, chapterSeconds(time, 10000000), null,
78
+ titleOf(await reader.bytes(at, length)), "und", index), timeTicks: String(time), timeScale: 10000000 });
79
+ at += length;
80
+ }
81
+ if (at !== field.end) throw new Error("Unexpected bytes after chpl entries");
82
+ return { id: "mp4:chpl", source: "chpl", ordered: false, chapters };
83
+ }
84
+
85
+ async function timedHeader(reader, field) {
86
+ const first = await payload(reader, field, 4);
87
+ if (first[0] > 1) throw new ChapterUnsupported("mp4-time-header-version");
88
+ const wide = first[0] === 1;
89
+ const data = await payload(reader, field, wide ? 32 : 20);
90
+ const scale = data.readUInt32BE(wide ? 20 : 12);
91
+ const duration = wide ? data.readBigUInt64BE(24) : BigInt(data.readUInt32BE(16));
92
+ if (scale === 0) throw new Error("Zero chapter time scale");
93
+ return { scale, duration, wide };
94
+ }
95
+
96
+ async function trackInfo(reader, field) {
97
+ const parts = await inside(reader, field);
98
+ const tkhd = one(parts, "tkhd");
99
+ const first = await payload(reader, tkhd, 4);
100
+ if (first[0] > 1) throw new ChapterUnsupported("tkhd-version");
101
+ const data = await payload(reader, tkhd, first[0] === 1 ? 24 : 16);
102
+ const id = data.readUInt32BE(first[0] === 1 ? 20 : 12);
103
+ const enabled = (first.readUIntBE(1, 3) & 1) !== 0;
104
+ const references = await inside(reader, one(parts, "tref"));
105
+ const refs = [];
106
+ for (const ref of references.filter((item) => item.type === "chap")) {
107
+ const size = ref.end - ref.dataOffset;
108
+ if (size % 4 || size / 4 > CHAPTER_LIMITS.entries) throw new Error("Invalid chap references");
109
+ const values = await payload(reader, ref, size);
110
+ for (let at = 0; at < size; at += 4) refs.push(values.readUInt32BE(at));
111
+ }
112
+ return { id, enabled, refs, parts };
113
+ }
114
+
115
+ async function sizesOf(reader, fields) {
116
+ const stsz = one(fields, "stsz");
117
+ if (stsz) {
118
+ const head = await payload(reader, stsz, 12);
119
+ const uniform = head.readUInt32BE(4), count = head.readUInt32BE(8);
120
+ if (count > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-sample-count");
121
+ if (stsz.dataOffset + 12 + (uniform ? 0 : 4 * count) !== stsz.end) throw new Error("Invalid chapter stsz");
122
+ const values = uniform ? null : await reader.bytes(stsz.dataOffset + 12, count * 4);
123
+ return Array.from({ length: count }, (_, index) => uniform || values.readUInt32BE(index * 4));
124
+ }
125
+ const compact = one(fields, "stz2");
126
+ const head = await payload(reader, compact, 12);
127
+ const width = head[7], count = head.readUInt32BE(8);
128
+ if (![4, 8, 16].includes(width)) throw new Error("Invalid stz2 field size");
129
+ if (count > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-sample-count");
130
+ const length = Math.ceil(width * count / 8);
131
+ if (compact.dataOffset + 12 + length !== compact.end) throw new Error("Invalid chapter stz2");
132
+ const values = await reader.bytes(compact.dataOffset + 12, length);
133
+ return Array.from({ length: count }, (_, index) => width === 16 ? values.readUInt16BE(index * 2)
134
+ : width === 8 ? values[index] : index % 2 ? values[index >> 1] & 15 : values[index >> 1] >> 4);
135
+ }
136
+
137
+ async function editMap(reader, parts, movieScale, trackScale, trackDuration) {
138
+ const edts = await inside(reader, one(parts, "edts"));
139
+ const elst = one(edts, "elst");
140
+ if (!elst) return [{ media: 0, movie: 0, duration: chapterSeconds(trackDuration, trackScale), index: 0 }];
141
+ const version = (await payload(reader, elst, 4))[0];
142
+ if (version > 1) throw new ChapterUnsupported("chapter-edit-version");
143
+ const width = version === 1 ? 20 : 12;
144
+ const entries = await table(reader, elst, width);
145
+ let movie = 0;
146
+ const result = [];
147
+ for (let index = 0; index < entries.count; index++) {
148
+ const at = index * width;
149
+ const duration = chapterSeconds(version === 1 ? entries.bytes.readBigUInt64BE(at) : BigInt(entries.bytes.readUInt32BE(at)), movieScale);
150
+ const media = version === 1 ? entries.bytes.readBigInt64BE(at + 8) : BigInt(entries.bytes.readInt32BE(at + 4));
151
+ const rate = version === 1 ? at + 16 : at + 8;
152
+ if (entries.bytes.readInt16BE(rate) !== 1 || entries.bytes.readInt16BE(rate + 2) !== 0) throw new ChapterUnsupported("chapter-edit-rate");
153
+ if (media < -1n) throw new Error("Invalid chapter edit media time");
154
+ if (media !== -1n) result.push({ media: chapterSeconds(media, trackScale), movie, duration, index });
155
+ movie += duration;
156
+ }
157
+ return result;
158
+ }
159
+
160
+ async function quicktime(reader, track, movieScale) {
161
+ const mdia = await inside(reader, one(track.parts, "mdia"));
162
+ const timing = await timedHeader(reader, one(mdia, "mdhd"));
163
+ const mdhd = await payload(reader, one(mdia, "mdhd"), timing.wide ? 34 : 22);
164
+ const lang = mdhd.readUInt16BE(timing.wide ? 32 : 20);
165
+ let language = lang ? String.fromCharCode(((lang >> 10) & 31) + 96, ((lang >> 5) & 31) + 96, (lang & 31) + 96) : "und";
166
+ if (lang < 0x400) language = MAC_LANGUAGES.get(lang) ?? "und";
167
+ if (!/^[a-z]{3}$/.test(language)) language = "und";
168
+ const elng = one(mdia, "elng");
169
+ if (elng) {
170
+ await payload(reader, elng, 4);
171
+ language = titleOf(await reader.bytes(elng.dataOffset + 4, elng.end - elng.dataOffset - 4)) || language;
172
+ }
173
+ const minf = await inside(reader, one(mdia, "minf"));
174
+ const dinf = await inside(reader, one(minf, "dinf"));
175
+ const dref = one(dinf, "dref");
176
+ const dataRefs = dref ? await boxes(reader, dref.dataOffset + 8, dref.end) : [];
177
+ if (dref && (await payload(reader, dref, 8)).readUInt32BE(4) !== dataRefs.length) throw new Error("Chapter data reference count mismatch");
178
+ const fields = await inside(reader, one(minf, "stbl"));
179
+ const stsd = one(fields, "stsd");
180
+ if (!stsd) throw new Error("Chapter track has no sample descriptions");
181
+ const descriptions = await boxes(reader, stsd.dataOffset + 8, stsd.end);
182
+ if ((await payload(reader, stsd, 8)).readUInt32BE(4) !== descriptions.length) throw new Error("Chapter sample description count mismatch");
183
+ if (!descriptions.length) throw new Error("Chapter track has no sample description");
184
+ for (const description of descriptions) {
185
+ if (!["text", "tx3g"].includes(description.type)) throw new ChapterUnsupported(`chapter-sample-format:${description.type}`);
186
+ const reference = (await payload(reader, description, 8)).readUInt16BE(6);
187
+ const source = dataRefs[reference - 1];
188
+ if (!source || !["url ", "alis"].includes(source.type) || ((await payload(reader, source, 4)).readUIntBE(1, 3) & 1) === 0) {
189
+ throw new ChapterUnsupported("external-chapter-data-reference");
190
+ }
191
+ }
192
+ const sizes = await sizesOf(reader, fields);
193
+ const durations = await table(reader, one(fields, "stts"), 8);
194
+ const times = [];
195
+ let ticks = 0n;
196
+ for (let index = 0; index < durations.count; index++) {
197
+ const count = durations.bytes.readUInt32BE(index * 8), delta = BigInt(durations.bytes.readUInt32BE(index * 8 + 4));
198
+ if (times.length + count > sizes.length) throw new Error("stts exceeds chapter sample count");
199
+ for (let sample = 0; sample < count; sample++) { times.push({ start: ticks, end: ticks + delta }); ticks += delta; }
200
+ }
201
+ if (times.length !== sizes.length) throw new Error("stts does not cover chapter samples");
202
+ const ctts = one(fields, "ctts");
203
+ if (ctts) {
204
+ const offsets = await table(reader, ctts, 8);
205
+ if (offsets.head[0] > 1) throw new ChapterUnsupported("chapter-ctts-version");
206
+ let sample = 0;
207
+ for (let index = 0; index < offsets.count; index++) {
208
+ const count = offsets.bytes.readUInt32BE(index * 8);
209
+ const offset = BigInt(offsets.head[0] === 1 ? offsets.bytes.readInt32BE(index * 8 + 4) : offsets.bytes.readUInt32BE(index * 8 + 4));
210
+ if (sample + count > times.length) throw new Error("ctts exceeds chapter samples");
211
+ for (let n = 0; n < count; n++, sample++) { times[sample].start += offset; times[sample].end += offset; }
212
+ }
213
+ if (sample !== times.length) throw new Error("ctts does not cover chapter samples");
214
+ }
215
+ const chunksField = one(fields, "stco") ?? one(fields, "co64");
216
+ const wide = chunksField?.type === "co64";
217
+ const chunks = await table(reader, chunksField, wide ? 8 : 4);
218
+ const runs = await table(reader, one(fields, "stsc"), 12);
219
+ const locations = [];
220
+ const formats = [];
221
+ let run = 0, sample = 0;
222
+ const runAt = (index) => ({ first: runs.bytes.readUInt32BE(index * 12), count: runs.bytes.readUInt32BE(index * 12 + 4), description: runs.bytes.readUInt32BE(index * 12 + 8) });
223
+ if (chunks.count && (!runs.count || runAt(0).first !== 1)) throw new Error("Invalid first chapter chunk mapping");
224
+ for (let index = 0; index < runs.count; index++) {
225
+ const current = runAt(index);
226
+ if (!current.count || current.description < 1 || current.description > descriptions.length || current.first > chunks.count || (index && current.first <= runAt(index - 1).first)) throw new Error("Invalid chapter chunk mapping");
227
+ }
228
+ for (let index = 0; index < chunks.count; index++) {
229
+ if (run + 1 < runs.count && runAt(run + 1).first === index + 1) run++;
230
+ let at = wide ? Number(chunks.bytes.readBigUInt64BE(index * 8)) : chunks.bytes.readUInt32BE(index * 4);
231
+ for (let n = 0; n < runAt(run).count; n++, sample++) {
232
+ if (sample >= sizes.length || !Number.isSafeInteger(at) || at < 0 || at + sizes[sample] > reader.size) throw new Error("Chapter sample outside file or count");
233
+ locations.push(at); formats.push(descriptions[runAt(run).description - 1].type); at += sizes[sample];
234
+ }
235
+ }
236
+ if (locations.length !== sizes.length) throw new Error("Chunk table does not cover chapter samples");
237
+ const edits = await editMap(reader, track.parts, movieScale, timing.scale, timing.duration);
238
+ const chapters = [];
239
+ for (let index = 0; index < sizes.length; index++) {
240
+ if (sizes[index] < 2 || sizes[index] > CHAPTER_LIMITS.textBytes + 65536) throw new ChapterUnsupported("chapter-text-sample-size");
241
+ const head = await reader.bytes(locations[index], 2);
242
+ const length = head.readUInt16BE();
243
+ if (length + 2 > sizes[index]) throw new Error("Chapter text exceeds sample");
244
+ const title = trackTitle(await reader.bytes(locations[index] + 2, length), formats[index], lang);
245
+ const start = chapterSeconds(times[index].start, timing.scale), end = chapterSeconds(times[index].end, timing.scale);
246
+ for (const edit of edits) {
247
+ if (end <= edit.media || start >= edit.media + edit.duration) continue;
248
+ chapters.push(chapter(`mp4:track:${track.id}:sample:${index}:edit:${edit.index}`,
249
+ edit.movie + Math.max(start - edit.media, 0), edit.movie + Math.min(end - edit.media, edit.duration), title, language, chapters.length));
250
+ }
251
+ }
252
+ return { id: `mp4:track:${track.id}`, source: "quicktime", ordered: false, chapters };
253
+ }
254
+
255
+ export async function readMp4Chapters(container, held) {
256
+ const cached = container.moovHeld;
257
+ const originalRead = held.read;
258
+ const ranges = held.ranges ? [...held.ranges] : undefined;
259
+ if (cached?.offset !== undefined) ranges?.push([cached.offset, cached.offset + cached.moov.length - 1]);
260
+ const reader = new ChapterBytes({ ...held, ranges, read: (start, end) => {
261
+ if (cached?.offset !== undefined && start >= cached.offset && end < cached.offset + cached.moov.length) return Promise.resolve(cached.moov.subarray(start - cached.offset, end - cached.offset + 1));
262
+ return originalRead(start, end);
263
+ } }, container.fileSize);
264
+ let moov = null;
265
+ for (let at = 0; at < reader.size;) {
266
+ const field = await box(reader, at, reader.size);
267
+ if (field.type === "moov") { moov = field; break; }
268
+ at = field.end;
269
+ }
270
+ if (!moov) return chapterResult("absent");
271
+ const parts = await inside(reader, moov);
272
+ const udta = await inside(reader, one(parts, "udta"));
273
+ const chpl = one(udta, "chpl");
274
+ const editions = [];
275
+ if (chpl) editions.push(await nero(reader, chpl));
276
+ let pending = null;
277
+ try {
278
+ const tracks = [];
279
+ for (const field of parts.filter((part) => part.type === "trak")) tracks.push(await trackInfo(reader, field));
280
+ const referrer = tracks.find((track) => track.enabled && track.refs.length);
281
+ if (referrer) {
282
+ const track = tracks.find((item) => item.id === referrer.refs[0]);
283
+ if (!track) throw new Error("Referenced chapter track is missing");
284
+ const extensions = await inside(reader, one(parts, "mvex"));
285
+ for (const defaults of extensions.filter((item) => item.type === "trex")) {
286
+ if ((await payload(reader, defaults, 8)).readUInt32BE(4) === track.id) {
287
+ // Fragment samples are not described by the initial sample tables.
288
+ throw new ChapterUnsupported("chapter-fragmented-track");
289
+ }
290
+ }
291
+ const timing = await timedHeader(reader, one(parts, "mvhd"));
292
+ editions.unshift(await quicktime(reader, track, timing.scale));
293
+ }
294
+ } catch (error) {
295
+ if (!editions.length) throw error;
296
+ if (isUnavailable(error)) pending = { state: "pending", start: error.start, end: error.end };
297
+ else throw error;
298
+ }
299
+ const selected = editions[0];
300
+ if (!selected || !selected.chapters.length) return chapterResult(pending ? "pending" : "absent", { editions, pending,
301
+ selectedEditionId: selected?.id ?? null });
302
+ let duration = container.mediaInfo?.durationSeconds ?? null;
303
+ if (duration === null && one(parts, "mvhd")) {
304
+ const timing = await timedHeader(reader, one(parts, "mvhd"));
305
+ if (timing.duration !== (timing.wide ? 0xffffffffffffffffn : 0xffffffffn)) duration = chapterSeconds(timing.duration, timing.scale);
306
+ }
307
+ return chapterResult("available", { editions, selectedEditionId: selected.id, complete: pending === null,
308
+ pending, representationsDisagree: editions.length > 1 && JSON.stringify(editions[0].chapters.map((item) => [item.startSeconds, item.titles[0].text])) !== JSON.stringify(editions[1].chapters.map((item) => [item.startSeconds, item.titles[0].text])),
309
+ sections: navigationSections(selected, duration) });
310
+ }
@@ -0,0 +1,9 @@
1
+ /** HTTP validation without torrent startup or media parsing. */
2
+ export class ChapterController {
3
+ constructor({ sourceRegistry, chapters }) { this.sourceRegistry = sourceRegistry; this.chapters = chapters; }
4
+ get({ sourceKey, fileIndex }) {
5
+ if (!this.sourceRegistry.get(sourceKey)) return { status: 404, body: { code: "SOURCE_NOT_FOUND", error: "Source key was not found." } };
6
+ if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return { status: 400, body: { error: "fileIndex must be a non-negative integer." } };
7
+ return { status: 200, body: { ...this.chapters.get(sourceKey, fileIndex), capability: "supported" } };
8
+ }
9
+ }
@@ -177,6 +177,7 @@ function buildHttpBaseUrl(host, port) {
177
177
  * @returns {object} The components, and the settings they read.
178
178
  */
179
179
  export function wireOutputs({
180
+ observeWait = null,
180
181
  enabled,
181
182
  ffmpegBin,
182
183
  localBindHost,
@@ -363,6 +364,7 @@ export function wireOutputs({
363
364
  get segmentDurationSec() { return parts.segmentDurationSec; },
364
365
  readsThroughCache: () => parts.inputCacheAvailable === true,
365
366
  segmentFiles,
367
+ observeWait,
366
368
  get videoEncoder() { return parts.encoders.current; },
367
369
  get encoders() { return parts.encoders; },
368
370
  invalidateWaits: (output) => parts.serving.invalidateWaits(output),
@@ -31,12 +31,14 @@ export class WorkerTorrentPool {
31
31
  /** Stand-ins by source key, so repeat calls return the same object. */
32
32
  #torrents = new Map();
33
33
  #pending = new Map();
34
+ #observed;
34
35
 
35
36
  /**
36
37
  * @param {{ memoryBytes?: number, stateDir?: string }} [options]
37
38
  * @param {object | null} [client] - The worker interface; tests supply a fake.
38
39
  */
39
40
  constructor(options = {}, client = null) {
41
+ this.#observed = options.onObserved;
40
42
  this.#client = client ?? new TorrentWorkerClient({ ...options, onSourceForgotten: event => {
41
43
  this.forget(event.sourceKey);
42
44
  options.onSourceForgotten?.(event);
@@ -51,13 +53,16 @@ export class WorkerTorrentPool {
51
53
  * @returns {Promise<object>}
52
54
  */
53
55
  async getTorrent(sourceType, source) {
56
+ const startedAt = performance.now();
54
57
  const sourceKey = await deriveSourceKey(sourceType, source);
58
+ const observed = outcome => this.#observed?.({ sourceKey, startedAt, endedAt: performance.now(), outcome });
55
59
  const existing = this.#torrents.get(sourceKey);
56
60
  if (existing) {
61
+ observed("cached");
57
62
  return existing;
58
63
  }
59
64
  const joined = this.#pending.get(sourceKey);
60
- if (joined) return joined;
65
+ if (joined) return joined.finally(() => observed("joined"));
61
66
  const pending = this.#client.getTorrent({ sourceKey, sourceType, source }).then(torrent => {
62
67
  if (this.#pending.get(sourceKey) !== pending) {
63
68
  const error = new Error("Source was forgotten while its metadata was being read.");
@@ -66,7 +71,10 @@ export class WorkerTorrentPool {
66
71
  }
67
72
  this.#torrents.set(sourceKey, torrent);
68
73
  return torrent;
69
- }).finally(() => { if (this.#pending.get(sourceKey) === pending) this.#pending.delete(sourceKey); });
74
+ }).finally(() => {
75
+ observed(this.#torrents.has(sourceKey) ? "ready" : "error");
76
+ if (this.#pending.get(sourceKey) === pending) this.#pending.delete(sourceKey);
77
+ });
70
78
  this.#pending.set(sourceKey, pending);
71
79
  return pending;
72
80
  }
@@ -713,6 +713,17 @@ export function createDataChannelHandler({
713
713
  * @type {Map<string, Map<string, DataChannel | null>>}
714
714
  */
715
715
  const viewersOnConnection = new Map();
716
+ // One selected chapter file per channel; removed with the channel.
717
+ const chapterChannels = new Map();
718
+
719
+ function publishChaptersChanged(event) {
720
+ for (const [channel, selected] of chapterChannels) {
721
+ if (selected.sourceKey === event.sourceKey && selected.fileIndex === event.fileIndex) {
722
+ try { send(channel, { type: "chapters-changed", ...event }); }
723
+ catch (error) { log(`[dc] chapter notification failed: ${error.message}`); }
724
+ }
725
+ }
726
+ }
716
727
 
717
728
  /**
718
729
  * The connections a viewer is still named on, oldest first.
@@ -799,6 +810,7 @@ export function createDataChannelHandler({
799
810
  * @returns {void}
800
811
  */
801
812
  function forgetChannel(channel) {
813
+ chapterChannels.delete(channel);
802
814
  for (const viewers of viewersOnConnection.values()) {
803
815
  for (const [consumerId, on] of viewers) {
804
816
  if (on === channel) {
@@ -1162,6 +1174,16 @@ export function createDataChannelHandler({
1162
1174
  return;
1163
1175
  }
1164
1176
 
1177
+ const chapterPath = /^\/api\/sources\/([^/]+)\/chapters$/.exec(path);
1178
+ if (method === "GET" && chapterPath) {
1179
+ const rawIndex = new URLSearchParams(query ?? "").get("fileIndex");
1180
+ const fileIndex = rawIndex !== null && /^\d+$/.test(rawIndex) ? Number(rawIndex) : NaN;
1181
+ if (Number.isSafeInteger(fileIndex) && fileIndex >= 0) {
1182
+ try { chapterChannels.set(channel, { sourceKey: decodeURIComponent(chapterPath[1]), fileIndex }); }
1183
+ catch (error) { log(`[dc] chapter source key cannot be decoded: ${error.message}`); }
1184
+ }
1185
+ }
1186
+
1165
1187
 
1166
1188
  const queryInfo = query ? `?${query}` : "";
1167
1189
  const bodyInfo =
@@ -1464,7 +1486,8 @@ export function createDataChannelHandler({
1464
1486
  handleChannel,
1465
1487
  publishSubtitleCues,
1466
1488
  /** What the film costs viewers' links beyond itself, per byte of film, or null. */
1467
- serviceShare: () => deliveryShares.serviceShare()
1489
+ serviceShare: () => deliveryShares.serviceShare(),
1490
+ publishChaptersChanged
1468
1491
  };
1469
1492
  }
1470
1493
 
@@ -0,0 +1,68 @@
1
+ /** Bounded, opt-in startup observations. No torrent or application imports. */
2
+ export class SourceWaitTrace {
3
+ constructor({ emit, now = () => performance.now() }) {
4
+ Object.assign(this, { emit, now, sources: new Map(), requests: new WeakMap() });
5
+ }
6
+
7
+ write(context, event, fields = {}) {
8
+ this.emit({ version: 1, clock: "proxy-main", wait: context.wait, attempt: context.attempt,
9
+ operation: context.operation, event, at: this.now(), ...fields });
10
+ }
11
+
12
+ remember(sourceKey, context) {
13
+ if (typeof sourceKey !== "string" || !sourceKey) return;
14
+ for (const [key, value] of this.sources) if (this.now() - value.seen > 60_000) this.sources.delete(key);
15
+ if (this.sources.size >= 64 && !this.sources.has(sourceKey)) this.sources.delete(this.sources.keys().next().value);
16
+ this.sources.set(sourceKey, { ...context, seen: this.now() });
17
+ }
18
+
19
+ source(sourceKey, event, fields = {}) {
20
+ const context = this.sources.get(sourceKey);
21
+ if (!context || this.now() - context.seen > 60_000) return;
22
+ this.write({ ...context, operation: null }, event, { sourceKey, ...fields });
23
+ }
24
+
25
+ install(app) {
26
+ app.addHook("onRequest", async (req, reply) => {
27
+ const wait = req.headers["x-ttv-wait-trace"];
28
+ const operation = req.headers["x-ttv-wait-operation"];
29
+ const attempt = Number(req.headers["x-ttv-wait-attempt"]);
30
+ if (typeof wait !== "string" || !/^[a-zA-Z0-9.-]{1,80}$/.test(wait) ||
31
+ typeof operation !== "string" || !/^b[0-9]{1,12}$/.test(operation) ||
32
+ !Number.isSafeInteger(attempt) || attempt < 0) return;
33
+ const context = { wait, attempt, operation, startedAt: this.now() };
34
+ this.requests.set(req, context);
35
+ this.write(context, "http-start", { method: req.method, path: req.url,
36
+ requestId: req.headers["x-ttv-forwarded-request-id"] ?? req.id });
37
+ let finished = false;
38
+ const end = outcome => {
39
+ if (finished) return;
40
+ finished = true;
41
+ this.write(context, "http-end", { outcome, status: reply.statusCode, duration: this.now() - context.startedAt });
42
+ };
43
+ reply.raw.once("finish", () => end("finished"));
44
+ reply.raw.once("close", () => end(reply.raw.writableFinished ? "finished" : "closed"));
45
+ });
46
+ app.addHook("preHandler", async req => {
47
+ const context = this.requests.get(req);
48
+ if (!context) return;
49
+ const match = /^\/api\/sources\/([^/?]+)/.exec(req.url);
50
+ const sourceKey = req.body?.sourceKey ?? req.query?.sourceKey ?? (match ? decodeURIComponent(match[1]) : null);
51
+ this.remember(sourceKey, context);
52
+ this.write(context, "http-handler", { sourceKey });
53
+ });
54
+ app.addHook("preSerialization", async (req, _reply, payload) => {
55
+ const context = this.requests.get(req);
56
+ if (context) {
57
+ this.remember(payload?.sourceKey, context);
58
+ this.write(context, "http-serialize");
59
+ }
60
+ return payload;
61
+ });
62
+ app.addHook("onSend", async (req, _reply, payload) => {
63
+ const context = this.requests.get(req);
64
+ if (context) this.write(context, "http-send");
65
+ return payload;
66
+ });
67
+ }
68
+ }