@torrent-tv/proxy 2.101.24 → 2.101.25

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.24",
3
+ "version": "2.101.25",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -63,10 +63,10 @@ function sanitizeLine(value) {
63
63
  *
64
64
  * @param {import("fastify").FastifyRequest} req
65
65
  * @param {import("fastify").FastifyReply} reply
66
- * @param {{ clientLogs: { write: (session: object, lines: string[]) => void } | null }} deps
66
+ * @param {{ clientLogs: { write: (session: object, lines: string[]) => void } | null, viewerRequests?: object }} deps
67
67
  * @returns {Promise<unknown>}
68
68
  */
69
- export async function handleApiClientLogsPost(req, reply, { clientLogs }) {
69
+ export async function handleApiClientLogsPost(req, reply, { clientLogs, viewerRequests }) {
70
70
  const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
71
71
  const session = {
72
72
  sessionId: safeString(body.sessionId, MAX_SID_LEN) || "unknown",
@@ -89,5 +89,18 @@ export async function handleApiClientLogsPost(req, reply, { clientLogs }) {
89
89
  if (lines.length > 0) {
90
90
  clientLogs?.write(session, lines);
91
91
  }
92
+ for (const row of rows) {
93
+ const message = row && typeof row.msg === "string" ? row.msg : "";
94
+ const match = /^\[first-frame-observation\] consumer=([^ ]+) generation=(\d+) waitId=([^ ]+) wait=(\d+) audioReady=(true|false) elapsedMs=([\d.]+)$/.exec(message);
95
+ if (match) {
96
+ const elapsedMs = Number(match[6]);
97
+ if (match[1].length <= 64 && Number.isSafeInteger(Number(match[2])) && match[3].length <= 256 &&
98
+ Number.isSafeInteger(Number(match[4])) && match[5] === "true" &&
99
+ Number.isFinite(elapsedMs) && elapsedMs >= 0) {
100
+ viewerRequests?.noteFirstFrameObservation?.({ consumerId: match[1], generation: Number(match[2]),
101
+ waitId: match[3], wait: Number(match[4]), elapsedMs });
102
+ }
103
+ }
104
+ }
92
105
  return reply.code(204).send();
93
106
  }
@@ -15,14 +15,15 @@ import { logger } from "../../../../utils/logger.js";
15
15
  * @param {import("fastify").FastifyReply} reply
16
16
  * @param {{
17
17
  * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
18
- * torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool
18
+ * torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool,
19
+ * viewerRequests?: object
19
20
  * }} deps
20
21
  * @returns {Promise<void>}
21
22
  */
22
23
 
23
24
  const CANCELLED = Symbol("cancelled");
24
25
 
25
- export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers }) {
26
+ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers, viewerRequests }) {
26
27
  const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
27
28
  if (!sourceKey) {
28
29
  return reply.code(400).send({ error: "sourceKey is required." });
@@ -72,13 +73,21 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
72
73
  }
73
74
  const consumerId = typeof req.query?.consumerId === "string" ? req.query.consumerId.trim() : "";
74
75
  if (consumerId) viewers?.visibleFiles(consumerId, sourceKey, contents.items.map(item => item.fileIndex));
76
+ const generationRaw = typeof req.query?.generation === "string" ? req.query.generation : "";
77
+ const generation = generationRaw !== "" && /^\d+$/.test(generationRaw) ? Number(generationRaw) : null;
78
+ const files = contents.files().map(file => {
79
+ if (!consumerId || !Number.isSafeInteger(generation) || file.kind !== "video" || file.excludedReason) return file;
80
+ const waitEstimate = viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey,
81
+ fileIndex: file.fileIndex, fileName: file.relativePath ?? file.name, fileLength: file.length }) ?? null;
82
+ return waitEstimate ? { ...file, waitEstimate } : file;
83
+ });
75
84
  return reply.send({
76
85
  name: torrent.name ?? "",
77
86
  infoHash: torrent.infoHash ?? "",
78
87
  // In the order a person reads them — by folder, then by name, with runs of
79
88
  // digits compared as numbers. A torrent's own order is whatever the tool
80
89
  // that made it chose, and it is routinely by size.
81
- files: contents.files(),
90
+ files,
82
91
  // The pictures, each with what belongs to it. By index, because the files
83
92
  // themselves are in the list above and saying them twice is how two copies
84
93
  // of one fact start.
@@ -13,11 +13,12 @@ import { logger } from "../../../../utils/logger.js";
13
13
  * @param {import("fastify").FastifyReply} reply
14
14
  * @param {{
15
15
  * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
16
- * torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool
16
+ * torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool,
17
+ * viewerRequests?: object
17
18
  * }} deps
18
19
  * @returns {Promise<void>}
19
20
  */
20
- export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
21
+ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests }) {
21
22
  const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
22
23
  if (!sourceKey) {
23
24
  return reply.code(400).send({ error: "sourceKey is required." });
@@ -28,6 +29,19 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
28
29
  return reply.code(404).send({ error: "Source key was not found." });
29
30
  }
30
31
 
32
+ const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
33
+ const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
34
+ const consumerId = typeof req.query.consumer === "string" ? req.query.consumer : "";
35
+ const generationRaw = typeof req.query.generation === "string" ? req.query.generation : "";
36
+ const generation = generationRaw !== "" && /^\d+$/.test(generationRaw) ? Number(generationRaw) : null;
37
+ const requestedFileName = typeof req.query.fileName === "string" ? req.query.fileName : "";
38
+ const requestedFileLength = typeof req.query.fileLength === "string" && /^\d+$/.test(req.query.fileLength)
39
+ ? Number(req.query.fileLength) : null;
40
+ const initialEstimate = () => consumerId && Number.isSafeInteger(generation) && Number.isSafeInteger(fileIndex)
41
+ ? viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey, fileIndex,
42
+ fileName: requestedFileName, fileLength: requestedFileLength }) ?? null
43
+ : null;
44
+
31
45
  let torrent;
32
46
  try {
33
47
  torrent = torrentPool.knownTorrent(sourceKey);
@@ -37,18 +51,18 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
37
51
  return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
38
52
  }
39
53
  if (!torrent) {
40
- return reply.code(202).send({ pending: true });
54
+ const waitEstimate = initialEstimate();
55
+ return reply.code(202).send({ pending: true, ...(waitEstimate ? { waitEstimate } : {}) });
41
56
  }
42
57
 
43
- const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
44
- const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
45
-
46
-
47
58
  // Awaited: with the torrent on its own thread this is a round trip, not a
48
59
  // local lookup. Without the await the reply was the pending promise itself,
49
60
  // which serialises to `{}` — the empty stats seen in the field 2026-08-02.
50
61
  const stats = await torrentPool.getFileStats(torrent, fileIndex);
51
- if (stats?.pending || !stats) return reply.code(202).send({ pending: true });
62
+ if (stats?.pending || !stats) {
63
+ const waitEstimate = initialEstimate();
64
+ return reply.code(202).send({ pending: true, ...(waitEstimate ? { waitEstimate } : {}) });
65
+ }
52
66
 
53
67
  // Diagnostic: surface the real swarm state per poll so a cold-start download
54
68
  // stall (0 peers / header not advancing → playback-plan blocks on the codec
@@ -121,5 +135,9 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
121
135
  `file=${filePct} header=${header}${detail}`
122
136
  );
123
137
 
124
- return reply.send(stats);
138
+ const waitEstimate = consumerId && Number.isSafeInteger(generation)
139
+ ? viewerRequests?.waitEstimateForSource?.({ consumerId, generation, sourceKey, fileIndex,
140
+ fileName: torrent.files?.[fileIndex]?.name ?? requestedFileName, fileLength: requestedFileLength, stats }) ?? null
141
+ : null;
142
+ return reply.send(waitEstimate ? { ...stats, waitEstimate } : stats);
125
143
  }
package/server.js CHANGED
@@ -1406,10 +1406,11 @@ export async function startProxyServer({
1406
1406
  return handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers });
1407
1407
  });
1408
1408
  app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
1409
- handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
1409
+ handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests: outputParts.viewerRequests })
1410
1410
  );
1411
1411
  app.get("/api/sources/:sourceKey/files", async (req, reply) =>
1412
- handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers: outputParts.viewers })
1412
+ handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers: outputParts.viewers,
1413
+ viewerRequests: outputParts.viewerRequests })
1413
1414
  );
1414
1415
  app.get("/api/sources/:sourceKey/files/:fileIndex/fingerprint", async (req, reply) =>
1415
1416
  handleApiSourceFingerprintGet(req, reply, {
@@ -1463,7 +1464,7 @@ export async function startProxyServer({
1463
1464
  ? createClientLogFiles(logFile)
1464
1465
  : createClientLogConsole((message) => logger.info(message));
1465
1466
  app.post("/api/client-logs", async (req, reply) =>
1466
- handleApiClientLogsPost(req, reply, { clientLogs })
1467
+ handleApiClientLogsPost(req, reply, { clientLogs, viewerRequests: outputParts.viewerRequests })
1467
1468
  );
1468
1469
  app.post("/api/playback-plan", async (req, reply) =>
1469
1470
  handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers: outputParts.viewers })
@@ -85,8 +85,11 @@ export class LocalObservations {
85
85
  /** @type {() => number} */
86
86
  #now;
87
87
 
88
- /** @type {{ speed: Record<string, object[]>, rates: Record<string, object[]>, preparation: Record<string, object[]> }} */
89
- #entries = { speed: {}, rates: {}, preparation: {} };
88
+ /** @type {Set<string>} */
89
+ #configurations = new Set();
90
+
91
+ /** @type {{ speed: Record<string, object[]>, rates: Record<string, object[]>, preparation: Record<string, object[]>, firstFrame: Record<string, object[]> }} */
92
+ #entries = { speed: {}, rates: {}, preparation: {}, firstFrame: {} };
90
93
 
91
94
  /**
92
95
  * @param {object} params
@@ -152,6 +155,7 @@ export class LocalObservations {
152
155
  * @returns {void}
153
156
  */
154
157
  load(currentConfigurations) {
158
+ this.#configurations = new Set(currentConfigurations);
155
159
  if (!this.#filePath) {
156
160
  return;
157
161
  }
@@ -168,11 +172,14 @@ export class LocalObservations {
168
172
  const allowed = new Set(currentConfigurations);
169
173
  let kept = 0;
170
174
  let dropped = 0;
171
- for (const kind of ["speed", "rates", "preparation"]) {
175
+ const firstFrameKey = this.#firstFrameKey();
176
+ for (const kind of ["speed", "rates", "preparation", "firstFrame"]) {
172
177
  const section = raw.entries[kind] && typeof raw.entries[kind] === "object" ? raw.entries[kind] : {};
173
178
  for (const [key, readings] of Object.entries(section)) {
174
- const configuration = key.split("|")[0];
175
- if (!allowed.has(configuration) || !Array.isArray(readings)) {
179
+ const matches = kind === "firstFrame"
180
+ ? key === firstFrameKey
181
+ : allowed.has(key.split("|")[0]);
182
+ if (!matches || !Array.isArray(readings)) {
176
183
  dropped += 1;
177
184
  continue;
178
185
  }
@@ -256,6 +263,75 @@ export class LocalObservations {
256
263
  this.#save();
257
264
  }
258
265
 
266
+ /**
267
+ * Keep a browser-measured wait outcome with the source and link readings
268
+ * visible before the container was inspected. These readings are useful
269
+ * only on the encoder configurations present on this host.
270
+ *
271
+ * @param {{ seconds: number, features: object }} params
272
+ * @returns {void}
273
+ */
274
+ noteFirstFrame({ seconds, features }) {
275
+ if (!Number.isFinite(seconds) || seconds < 0 || !features || typeof features !== "object") return;
276
+ const key = this.#firstFrameKey();
277
+ const readings = [...(this.#entries.firstFrame[key] ?? []), {
278
+ seconds,
279
+ extension: typeof features.extension === "string" ? features.extension : "",
280
+ fileLength: positive(features.fileLength),
281
+ downloadSpeed: nonNegative(features.downloadSpeed),
282
+ linkMbps: nonNegative(features.linkMbps),
283
+ headerRemaining: nonNegative(features.headerRemaining),
284
+ peers: nonNegative(features.peers),
285
+ at: this.#now()
286
+ }].slice(-READINGS_KEPT);
287
+ this.#entries.firstFrame[key] = readings;
288
+ this.#save();
289
+ }
290
+
291
+ /**
292
+ * Predict this host's first-frame wait from the closest measured start with
293
+ * the same media suffix. Distance is the sum of squared log-ratios for the
294
+ * independent measured inputs; it does not mix bytes, bit rates and peers
295
+ * in their raw units.
296
+ *
297
+ * @param {object} features
298
+ * @returns {number | null}
299
+ */
300
+ firstFrameSeconds(features) {
301
+ if (!features || typeof features !== "object") return null;
302
+ const readings = this.#entries.firstFrame[this.#firstFrameKey()] ?? [];
303
+ const candidates = readings.filter(reading => reading.extension === (features.extension ?? ""));
304
+ if (candidates.length === 0) return null;
305
+ const dimensions = ["fileLength", "downloadSpeed", "linkMbps", "headerRemaining", "peers"];
306
+ const scales = new Map(dimensions.map(key => [key, median(candidates
307
+ .map(reading => nonNegative(reading[key]))
308
+ .filter(value => value !== null && value > 0))]));
309
+ let best = null;
310
+ for (const reading of candidates) {
311
+ let distance = 0;
312
+ let used = 0;
313
+ for (const key of dimensions) {
314
+ const scale = scales.get(key);
315
+ const current = nonNegative(features[key]);
316
+ const previous = nonNegative(reading[key]);
317
+ if (scale === null || current === null || previous === null) continue;
318
+ const difference = Math.log1p(current / scale) - Math.log1p(previous / scale);
319
+ distance += difference * difference;
320
+ used += 1;
321
+ }
322
+ if (used === 0) continue;
323
+ distance /= used;
324
+ if (!best || distance < best.distance || (distance === best.distance && reading.at > best.reading.at)) {
325
+ best = { distance, reading };
326
+ }
327
+ }
328
+ return best?.reading.seconds ?? null;
329
+ }
330
+
331
+ #firstFrameKey() {
332
+ return `firstFrame:${[...this.#configurations].sort().join("||")}`;
333
+ }
334
+
259
335
  /**
260
336
  * The slowest this mode has been seen running alone on comparable material,
261
337
  * or null where nothing comparable has been seen.
@@ -349,6 +425,27 @@ export class LocalObservations {
349
425
  }
350
426
  }
351
427
 
428
+ /** @param {unknown} value @returns {number | null} */
429
+ function positive(value) {
430
+ const number = Number(value);
431
+ return Number.isFinite(number) && number > 0 ? number : null;
432
+ }
433
+
434
+ /** @param {unknown} value @returns {number | null} */
435
+ function nonNegative(value) {
436
+ if (value === null || value === undefined || value === "") return null;
437
+ const number = Number(value);
438
+ return Number.isFinite(number) && number >= 0 ? number : null;
439
+ }
440
+
441
+ /** @param {number[]} values @returns {number | null} */
442
+ function median(values) {
443
+ if (values.length === 0) return null;
444
+ const sorted = [...values].sort((left, right) => left - right);
445
+ const middle = Math.floor(sorted.length / 2);
446
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
447
+ }
448
+
352
449
  /**
353
450
  * What an observation of an encode is filed as having been made FROM.
354
451
  *
@@ -60,6 +60,8 @@ export class ViewerRequests {
60
60
  #host;
61
61
  /** @type {WeakMap<object, Map<string, object>>} */
62
62
  #rateStateBySession = new WeakMap();
63
+ /** @type {Map<string, { consumerId: string, generation: number, fileIndex: number | null, waitId: string, features: object, updatedAt: number, revision: number, observedWaits: Set<number> }>} */
64
+ #earlyWaits = new Map();
63
65
  /**
64
66
  * What the start forecasts cost, said once a minute: how many there were,
65
67
  * their time on the main thread, and for each track whether the times of its
@@ -97,6 +99,131 @@ export class ViewerRequests {
97
99
  this.#host = host;
98
100
  }
99
101
 
102
+ /**
103
+ * Attach a host-matched estimate to the file list before the viewer chooses
104
+ * a file, so the original action need not wait for a torrent-worker round trip.
105
+ *
106
+ * @param {{ consumerId: string, generation: number, sourceKey: string, fileIndex: number, fileName: string, fileLength: number }} params
107
+ * @returns {object | null}
108
+ */
109
+ waitEstimateForFile({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength }) {
110
+ if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string" ||
111
+ !Number.isSafeInteger(fileIndex) || fileIndex < 0) return null;
112
+ const waitId = `${sourceKey}:${fileIndex}:${generation}`;
113
+ const key = `${consumerId}:${waitId}`;
114
+ const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
115
+ const features = {
116
+ extension,
117
+ fileLength,
118
+ downloadSpeed: null,
119
+ linkMbps: this.#host.viewers.get(consumerId)?.linkReading()?.linkMbps,
120
+ headerRemaining: null,
121
+ peers: null
122
+ };
123
+ const current = this.#earlyWaits.get(key);
124
+ if (current?.waitId === waitId && current.hasSourceObservation) {
125
+ const seconds = this.#host.firstFrameSecondsOf?.(current.features) ?? null;
126
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
127
+ return { version: 1, waitId, revision: current.revision, remainingSeconds: seconds, ageMs: 0 };
128
+ }
129
+ const sameWait = current?.waitId === waitId;
130
+ const revision = sameWait ? current.revision : 0;
131
+ const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current });
132
+ const changed = this.#recordEarlyWaitSnapshot(context, features);
133
+ if (sameWait && changed) context.revision += 1;
134
+ this.#pruneEarlyWaits();
135
+ const seconds = this.#host.firstFrameSecondsOf?.(features) ?? null;
136
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
137
+ return { version: 1, waitId, revision, remainingSeconds: seconds, ageMs: 0 };
138
+ }
139
+
140
+ /**
141
+ * Keep the measurements available before the container is inspected and
142
+ * predict the complete first-frame wait from this host's earlier outcomes.
143
+ *
144
+ * @param {{ consumerId: string, generation: number, sourceKey: string, fileIndex: number | null, fileName?: string, fileLength?: number | null, stats?: object }} params
145
+ * @returns {object | null}
146
+ */
147
+ waitEstimateForSource({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null, stats = {} }) {
148
+ if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return null;
149
+ const waitId = `${sourceKey}:${fileIndex ?? "all"}:${generation}`;
150
+ const key = `${consumerId}:${waitId}`;
151
+ const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
152
+ const features = {
153
+ extension,
154
+ fileLength: Number.isFinite(stats?.fileLength) && stats.fileLength > 0
155
+ ? stats.fileLength : fileLength,
156
+ downloadSpeed: stats?.downloadSpeed,
157
+ linkMbps: this.#host.viewers.get(consumerId)?.linkReading()?.linkMbps,
158
+ headerRemaining: Number.isFinite(stats?.headerBytes) && Number.isFinite(stats?.headerDownloadedBytes)
159
+ ? Math.max(0, stats.headerBytes - stats.headerDownloadedBytes) : null,
160
+ peers: stats?.connectedPeers ?? stats?.numPeers
161
+ };
162
+ const current = this.#earlyWaits.get(key);
163
+ const revision = current?.waitId === waitId ? current.revision + 1 : 0;
164
+ const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
165
+ sourceObserved: true });
166
+ this.#recordEarlyWaitSnapshot(context, features);
167
+ this.#pruneEarlyWaits();
168
+ const seconds = this.#host.firstFrameSecondsOf?.(features) ?? null;
169
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
170
+ return { version: 1, waitId, revision, remainingSeconds: seconds, ageMs: 0 };
171
+ }
172
+
173
+ /**
174
+ * Learn from the browser's presented-frame measurement for the current
175
+ * viewing. The saved source and link readings are the ones available at
176
+ * selection time, before the full track plan existed.
177
+ *
178
+ * @param {{ consumerId: string, generation: number, waitId: string, wait: number, elapsedMs: number }} params
179
+ * @returns {void}
180
+ */
181
+ noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs }) {
182
+ const key = `${consumerId}:${waitId}`;
183
+ const early = this.#earlyWaits.get(key);
184
+ if (!early || early.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 || early.observedWaits.has(wait) ||
185
+ !Number.isFinite(elapsedMs) || elapsedMs < 0) return;
186
+ for (const snapshot of early.snapshots) {
187
+ this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: snapshot });
188
+ }
189
+ early.observedWaits.add(wait);
190
+ early.updatedAt = Date.now();
191
+ }
192
+
193
+ #setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current, sourceObserved = false }) {
194
+ const sameWait = current?.waitId === waitId;
195
+ const context = sameWait ? current : {
196
+ consumerId,
197
+ generation,
198
+ fileIndex,
199
+ waitId,
200
+ snapshots: [],
201
+ hasSourceObservation: false,
202
+ observedWaits: new Set()
203
+ };
204
+ Object.assign(context, { consumerId, generation, fileIndex, waitId, features, updatedAt: Date.now(), revision,
205
+ hasSourceObservation: context.hasSourceObservation || sourceObserved });
206
+ this.#earlyWaits.set(`${consumerId}:${waitId}`, context);
207
+ return context;
208
+ }
209
+
210
+ #recordEarlyWaitSnapshot(context, features) {
211
+ const previous = context.snapshots.at(-1);
212
+ if (previous && JSON.stringify(previous) === JSON.stringify(features)) return false;
213
+ const snapshot = { ...features };
214
+ if (context.snapshots.length < 2) context.snapshots.push(snapshot);
215
+ else context.snapshots[1] = snapshot;
216
+ return true;
217
+ }
218
+
219
+ #pruneEarlyWaits() {
220
+ const cutoff = Date.now() - 30 * 60_000;
221
+ for (const [key, value] of this.#earlyWaits) {
222
+ if (value.updatedAt < cutoff) this.#earlyWaits.delete(key);
223
+ }
224
+ while (this.#earlyWaits.size > 1000) this.#earlyWaits.delete(this.#earlyWaits.keys().next().value);
225
+ }
226
+
100
227
  /**
101
228
  * Open an output of a file for this viewer and put them on it.
102
229
  *
@@ -650,6 +650,8 @@ export function wireOutputs({
650
650
  const predicted = Number(qualityStateOf(output).predictedSpeedWhenOffered);
651
651
  return Number.isFinite(predicted) && predicted > 0 ? predicted : null;
652
652
  },
653
+ firstFrameSecondsOf: (features) => parts.localObservations?.firstFrameSeconds(features) ?? null,
654
+ noteFirstFrame: (observation) => parts.localObservations?.noteFirstFrame(observation),
653
655
  audioDescriptionOf,
654
656
  openSecondsOf: (output) => runStartSecondsOf(output),
655
657
  processingStartupSecondsOf: (output) => runStartSecondsOf(output),