@torrent-tv/proxy 2.102.0 → 2.102.2

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.102.0",
3
+ "version": "2.102.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
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";
@@ -157,6 +158,8 @@ export async function startProxyServer({
157
158
  bodyLimit: 256 * 1024 * 1024 // 256 MB
158
159
  });
159
160
  const forwardedSessionResponseStarts = new WeakMap();
161
+ const waitTrace = new SourceWaitTrace({ emit: value => logger.info(`[wait-trace] ${JSON.stringify(value)}`) });
162
+ waitTrace.install(app);
160
163
  const logForwardedSessionResponseStage = (stage, req) => {
161
164
  const startedAt = forwardedSessionResponseStarts.get(req);
162
165
  const requestId = req.headers["x-ttv-forwarded-request-id"];
@@ -175,7 +178,7 @@ export async function startProxyServer({
175
178
  await app.register(fastifyCors, {
176
179
  origin: true,
177
180
  methods: ["GET", "POST", "OPTIONS"],
178
- allowedHeaders: ["Content-Type", "Range"]
181
+ allowedHeaders: ["Content-Type", "Range", "X-Ttv-Wait-Trace", "X-Ttv-Wait-Attempt", "X-Ttv-Wait-Operation"]
179
182
  });
180
183
 
181
184
  // Allow browser requests from an HTTPS page to this private-network proxy
@@ -220,6 +223,7 @@ export async function startProxyServer({
220
223
  // 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
221
224
  // adapter keeps TorrentPool's interface, so nothing downstream changed.
222
225
  const torrentPool = new WorkerTorrentPool({
226
+ onObserved: fields => waitTrace.source(fields.sourceKey, "torrent-ready", fields),
223
227
  memoryBytes,
224
228
  stateDir,
225
229
  heapSnapshots,
@@ -250,6 +254,8 @@ export async function startProxyServer({
250
254
  // only once the worker thread is up and a piece has verified, which is long
251
255
  // after the declarations below have run.
252
256
  onPiecesArrived: ({ sourceKey, fileIndexes, arrived }) => {
257
+ waitTrace.source(sourceKey, "held-bytes-changed", { fileIndexes, arrived: arrived?.slice?.(0, 32),
258
+ arrivedCount: arrived?.length });
253
259
  for (const fileIndex of fileIndexes) {
254
260
  mediaReads.bytesChanged(sourceKey, fileIndex);
255
261
  probeReads.bytesChanged(sourceKey, fileIndex);
@@ -871,6 +877,7 @@ export async function startProxyServer({
871
877
  } }),
872
878
  label: String(file.name ?? ""),
873
879
  fileOffset: file.offset,
880
+ onObserved: fields => waitTrace.source(sourceKey, "container-read", { fileIndex, requestId, ...fields }),
874
881
  onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
875
882
  onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
876
883
  onReadResult: async (statement, result, revision) => {
@@ -918,6 +925,7 @@ export async function startProxyServer({
918
925
  });
919
926
  let sourcePreparation;
920
927
  const outputParts = wireOutputs({
928
+ observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
921
929
  pinnedPreset,
922
930
  indexMemory,
923
931
  serviceShare,
@@ -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
@@ -305,6 +305,16 @@ export class LocalObservations {
305
305
  * @returns {number | null}
306
306
  */
307
307
  firstFrameSeconds(features) {
308
+ return this.firstFrameEstimate(features)?.seconds ?? null;
309
+ }
310
+
311
+ /**
312
+ * Return the current stage-median forecast and the measurements that formed it.
313
+ *
314
+ * @param {object} features
315
+ * @returns {{ seconds: number, stagesMs: object, observations: number } | null}
316
+ */
317
+ firstFrameEstimate(features) {
308
318
  if (!features || typeof features !== "object") return null;
309
319
  const stages = ["transportMs", "planMs", "prepareMs", "prebufferMs", "presentationMs"];
310
320
  const candidates = [];
@@ -319,7 +329,11 @@ export class LocalObservations {
319
329
  const prices = stages.map(stage => median(candidates.map(reading => reading[stage])
320
330
  .filter(Number.isFinite)));
321
331
  if (prices.some(value => value === null)) return null;
322
- return prices.reduce((sum, value) => sum + value, 0) / 1000;
332
+ return {
333
+ seconds: prices.reduce((sum, value) => sum + value, 0) / 1000,
334
+ stagesMs: Object.fromEntries(stages.map((stage, index) => [stage, prices[index]])),
335
+ observations: candidates.length
336
+ };
323
337
  }
324
338
 
325
339
  #firstFrameKey() {
@@ -156,13 +156,17 @@ export class ContainerOrchestrator {
156
156
  // table of the whole film it does not use (r38, torrent-tv/meta#178).
157
157
  const aside = this.#answersWithoutReading(key, statement);
158
158
  const previous = aside ? Promise.resolve() : this.#reads.get(key) ?? Promise.resolve();
159
- const askedAt = Date.now();
159
+ const askedAt = performance.now();
160
160
  const reading = previous.catch(() => undefined).then(async () => {
161
161
  if (!isCurrent()) return { result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } };
162
- const startedAt = Date.now();
162
+ const startedAt = performance.now();
163
163
  const revision = params.onReadStart?.(statement);
164
164
  const result = await this.#inspectRead({ ...params, isCurrent }, statement);
165
- 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 });
166
170
  return { result, revision };
167
171
  });
168
172
  if (!aside) this.#reads.set(key, reading);
@@ -125,11 +125,13 @@ export class ViewerRequests {
125
125
  const changed = this.#recordEarlyWaitSnapshot(context, context.features);
126
126
  if (changed) context.revision += 1;
127
127
  this.#pruneEarlyWaits();
128
- const totalSeconds = Number.isFinite(context.activeTotalSeconds)
129
- ? context.activeTotalSeconds
130
- : this.#host.firstFrameSecondsOf?.(context.features) ?? null;
131
- const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
128
+ const elapsedMs = waitElapsedSince(context, waitElapsedMs);
129
+ const historical = Number.isFinite(context.activeTotalSeconds) ? null : this.#historicalFirstFrame(context.features);
130
+ const totalSeconds = Number.isFinite(context.activeTotalSeconds) ? context.activeTotalSeconds : historical?.seconds ?? null;
131
+ const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
132
132
  if (!Number.isFinite(seconds) || seconds < 0) return null;
133
+ this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical,
134
+ firstRequiredSegmentsSeconds: context.activeFirstRequiredSegmentsSeconds });
133
135
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
134
136
  }
135
137
  const sameWait = current?.waitId === waitId;
@@ -139,11 +141,13 @@ export class ViewerRequests {
139
141
  const changed = this.#recordEarlyWaitSnapshot(context, context.features);
140
142
  if (sameWait && changed) context.revision += 1;
141
143
  this.#pruneEarlyWaits();
142
- const totalSeconds = Number.isFinite(context.activeTotalSeconds)
143
- ? context.activeTotalSeconds
144
- : this.#host.firstFrameSecondsOf?.(context.features) ?? null;
145
- const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
144
+ const elapsedMs = waitElapsedSince(context, waitElapsedMs);
145
+ const historical = Number.isFinite(context.activeTotalSeconds) ? null : this.#historicalFirstFrame(context.features);
146
+ const totalSeconds = Number.isFinite(context.activeTotalSeconds) ? context.activeTotalSeconds : historical?.seconds ?? null;
147
+ const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
146
148
  if (!Number.isFinite(seconds) || seconds < 0) return null;
149
+ this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical,
150
+ firstRequiredSegmentsSeconds: context.activeFirstRequiredSegmentsSeconds });
147
151
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
148
152
  }
149
153
 
@@ -192,20 +196,44 @@ export class ViewerRequests {
192
196
  ? elapsedMs / 1000 + measuredWorkSeconds
193
197
  : context.activeTotalSeconds;
194
198
  const activeEstimateChanged = hasActiveEstimate && activeTotalSeconds !== context.activeTotalSeconds;
195
- if (hasActiveEstimate) context.activeTotalSeconds = activeTotalSeconds;
199
+ if (hasActiveEstimate) {
200
+ context.activeTotalSeconds = activeTotalSeconds;
201
+ context.activeFirstRequiredSegmentsSeconds = measuredWorkSeconds;
202
+ }
196
203
  const changed = featuresChanged || activeEstimateChanged;
197
204
  if (sameWait && changed) context.revision += 1;
198
205
  this.#pruneEarlyWaits();
199
206
  const progressHasComponentForecast = Object.hasOwn(processing, "firstRequiredSegmentsSeconds");
200
207
  let totalSeconds = activeTotalSeconds;
208
+ let historical = null;
201
209
  if (!Number.isFinite(totalSeconds) && !progressHasComponentForecast) {
202
- totalSeconds = this.#host.firstFrameSecondsOf?.(context.features) ?? null;
210
+ historical = this.#historicalFirstFrame(context.features);
211
+ totalSeconds = historical?.seconds ?? null;
203
212
  }
204
213
  const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
205
214
  if (!Number.isFinite(seconds) || seconds < 0) return null;
215
+ this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical,
216
+ firstRequiredSegmentsSeconds: hasActiveEstimate ? measuredWorkSeconds : context.activeFirstRequiredSegmentsSeconds });
206
217
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
207
218
  }
208
219
 
220
+ #historicalFirstFrame(features) {
221
+ const estimate = this.#host.firstFrameSecondsOf?.(features) ?? null;
222
+ return Number.isFinite(estimate) ? { seconds: estimate, stagesMs: null, observations: null } : estimate;
223
+ }
224
+
225
+ #logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical, firstRequiredSegmentsSeconds }) {
226
+ if (context.loggedForecastRevision === context.revision) return;
227
+ const basis = Number.isFinite(firstRequiredSegmentsSeconds) ? "first-required" : "historical-stages";
228
+ const estimate = basis === "first-required"
229
+ ? { firstRequiredSegmentsSeconds }
230
+ : { stagesMs: historical?.stagesMs ?? null, observations: historical?.observations ?? null };
231
+ logger.info(`early wait forecast ${JSON.stringify({ waitId: context.waitId, consumer: context.consumerId,
232
+ generation: context.generation, features: context.features, elapsedMs, revision: context.revision,
233
+ predicted: { remainingSeconds: seconds, totalSeconds, basis, ...estimate } })}`);
234
+ context.loggedForecastRevision = context.revision;
235
+ }
236
+
209
237
  /**
210
238
  * Learn from the browser's presented-frame measurement for the current
211
239
  * viewing. The saved source and link readings are the ones available at
@@ -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),
@@ -649,7 +651,7 @@ export function wireOutputs({
649
651
  const predicted = Number(qualityStateOf(output).predictedSpeedWhenOffered);
650
652
  return Number.isFinite(predicted) && predicted > 0 ? predicted : null;
651
653
  },
652
- firstFrameSecondsOf: (features) => parts.localObservations?.firstFrameSeconds(features) ?? null,
654
+ firstFrameSecondsOf: (features) => parts.localObservations?.firstFrameEstimate(features) ?? null,
653
655
  noteFirstFrame: (observation) => parts.localObservations?.noteFirstFrame(observation),
654
656
  audioDescriptionOf,
655
657
  openSecondsOf: (output) => runStartSecondsOf(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
  }
@@ -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
+ }