@torrent-tv/proxy 2.107.52 → 2.107.54

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.107.52",
3
+ "version": "2.107.54",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -33,6 +33,11 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
33
33
  return reply.code(404).send({ error: "Source key was not found." });
34
34
  }
35
35
 
36
+ const consumerId = typeof req.query?.consumerId === "string" ? req.query.consumerId.trim() : "";
37
+ const generationRaw = typeof req.query?.generation === "string" ? req.query.generation : "";
38
+ const generation = generationRaw !== "" && /^\d+$/.test(generationRaw) ? Number(generationRaw) : null;
39
+ const viewerToken = viewerRequests?.viewerTokenOf?.(consumerId) ?? null;
40
+
36
41
  let cancelled;
37
42
  const waitPromise = new Promise(resolve => { cancelled = () => resolve(CANCELLED); });
38
43
  reply.raw?.once?.("close", cancelled);
@@ -71,10 +76,7 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
71
76
  if (file.excludedReason) logger.info(`contents request=${req.id ?? "unstated"} source=${sourceKey} ` +
72
77
  `file=${file.fileIndex} name=${JSON.stringify(file.relativePath)} bytes=${file.length} excluded=${file.excludedReason}`);
73
78
  }
74
- const consumerId = typeof req.query?.consumerId === "string" ? req.query.consumerId.trim() : "";
75
79
  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
80
  const linkMbps = Number(req.query?.linkMbps);
79
81
  const linkSampleAgeMs = Number(req.query?.linkSampleAgeMs);
80
82
  const earlyLink = Number.isFinite(linkMbps) && linkMbps > 0
@@ -84,7 +86,7 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
84
86
  if (!consumerId || !Number.isSafeInteger(generation) || file.kind !== "video" || file.excludedReason) return file;
85
87
  viewerRequests?.noteWaitFileSnapshot?.({ consumerId, generation, sourceKey,
86
88
  fileIndex: file.fileIndex, fileName: file.relativePath ?? file.name, fileLength: file.length,
87
- ...earlyLink });
89
+ ...earlyLink, ...(viewerToken ? { viewerToken } : {}) });
88
90
  return file;
89
91
  });
90
92
  return reply.send({
@@ -34,6 +34,7 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
34
34
  const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
35
35
  const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
36
36
  const consumerId = typeof req.query.consumer === "string" ? req.query.consumer : "";
37
+ const viewerToken = viewerRequests?.viewerTokenOf?.(consumerId) ?? null;
37
38
  const generationRaw = typeof req.query.generation === "string" ? req.query.generation : "";
38
39
  const generation = generationRaw !== "" && /^\d+$/.test(generationRaw) ? Number(generationRaw) : null;
39
40
  const requestedFileName = typeof req.query.fileName === "string" ? req.query.fileName : "";
@@ -47,7 +48,8 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
47
48
  const noteInitialSnapshot = () => {
48
49
  if (consumerId && Number.isSafeInteger(generation) && Number.isSafeInteger(fileIndex)) {
49
50
  viewerRequests?.noteWaitFileSnapshot?.({ consumerId, generation, sourceKey, fileIndex,
50
- fileName: requestedFileName, fileLength: requestedFileLength, ...earlyLink });
51
+ fileName: requestedFileName, fileLength: requestedFileLength, ...earlyLink,
52
+ ...(viewerToken ? { viewerToken } : {}) });
51
53
  }
52
54
  };
53
55
 
@@ -68,12 +70,10 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
68
70
  // local lookup. Without the await the reply was the pending promise itself,
69
71
  // which serialises to `{}` — the empty stats seen in the field 2026-08-02.
70
72
  const statsStartedAt = performance.now();
71
- let statsCompletedAt;
72
73
  let stats;
73
74
  try {
74
75
  stats = await torrentPool.getFileStats(torrent, fileIndex);
75
76
  } finally {
76
- statsCompletedAt = performance.now();
77
77
  logger.info(`[stats] ${sourceKey.slice(0, 8)} getFileStatsMs=${(performance.now() - statsStartedAt).toFixed(1)}`);
78
78
  }
79
79
  if (stats?.pending || !stats) {
@@ -155,13 +155,15 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
155
155
  if (consumerId && Number.isSafeInteger(generation)) {
156
156
  viewerRequests?.noteWaitSourceSnapshot?.({ consumerId, generation, sourceKey, fileIndex,
157
157
  fileName: torrent.files?.[fileIndex]?.name ?? requestedFileName, fileLength: requestedFileLength,
158
- stats, ...earlyLink });
158
+ stats, ...earlyLink, ...(viewerToken ? { viewerToken } : {}) });
159
159
  }
160
160
  const responsePreparedAtProxyMs = performance.now();
161
161
  return reply.send({ ...stats, sourceSnapshotTransport: {
162
+ clockEpoch: performance.timeOrigin,
163
+ snapshotClockEpoch: stats.sourceCapture?.clockEpoch ?? null,
162
164
  requestReceivedAtProxyMs,
163
- snapshotStartedAtProxyMs: statsStartedAt,
164
- snapshotCompletedAtProxyMs: statsCompletedAt,
165
+ snapshotStartedAtProxyMs: stats.sourceCapture?.validity === "valid" ? stats.sourceCapture.startedAt : null,
166
+ snapshotCompletedAtProxyMs: stats.sourceCapture?.validity === "valid" ? stats.sourceCapture.completedAt : null,
165
167
  responsePreparedAtProxyMs
166
168
  } });
167
169
  }
@@ -117,12 +117,30 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply,
117
117
  linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
118
118
  linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : undefined,
119
119
  linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : undefined,
120
+ linkSampleAgeMs: Number.isFinite(body.linkSampleAgeMs) && body.linkSampleAgeMs >= 0
121
+ ? body.linkSampleAgeMs : undefined,
120
122
  appendSessionId: sessionId,
121
123
  appendEpoch: typeof body.appendEpoch === "string" ? body.appendEpoch : undefined,
122
124
  appendEpochStartedAt: Number.isFinite(body.appendEpochStartedAt) && body.appendEpochStartedAt > 0
123
125
  ? body.appendEpochStartedAt : undefined,
124
126
  appendSnapshotAt: Number.isFinite(body.appendSnapshotAt) && body.appendSnapshotAt > 0
125
127
  ? body.appendSnapshotAt : undefined,
128
+ captureClockEpoch: Number.isFinite(body.captureClockEpoch) ? body.captureClockEpoch : undefined,
129
+ captureEpoch: typeof body.captureEpoch === "string" ? body.captureEpoch : undefined,
130
+ captureEpochStartedAtBrowser: Number.isFinite(body.captureEpochStartedAtBrowser)
131
+ ? body.captureEpochStartedAtBrowser : undefined,
132
+ captureRevision: Number.isSafeInteger(body.captureRevision) ? body.captureRevision : undefined,
133
+ captureStartedAtBrowser: Number.isFinite(body.captureStartedAtBrowser) ? body.captureStartedAtBrowser : undefined,
134
+ captureCompletedAtBrowser: Number.isFinite(body.captureCompletedAtBrowser)
135
+ ? body.captureCompletedAtBrowser : undefined,
136
+ linkSampleStartedAtBrowser: Number.isFinite(body.linkSampleStartedAtBrowser)
137
+ ? body.linkSampleStartedAtBrowser : undefined,
138
+ linkSampleCompletedAtBrowser: Number.isFinite(body.linkSampleCompletedAtBrowser)
139
+ ? body.linkSampleCompletedAtBrowser : undefined,
140
+ linkSampleActionId: typeof body.linkSampleActionId === "string" ? body.linkSampleActionId : undefined,
141
+ linkSampleGeneration: Number.isSafeInteger(body.linkSampleGeneration) ? body.linkSampleGeneration : undefined,
142
+ linkSampleClockEpoch: Number.isFinite(body.linkSampleClockEpoch) ? body.linkSampleClockEpoch : undefined,
143
+ linkSampleRevision: Number.isSafeInteger(body.linkSampleRevision) ? body.linkSampleRevision : undefined,
126
144
  appendService: body.appendService && typeof body.appendService === "object" ? body.appendService : undefined,
127
145
  fragmentService: body.fragmentService && typeof body.fragmentService === "object" ? body.fragmentService : undefined,
128
146
  bufferedAheadSec,
@@ -19,13 +19,21 @@ export async function handleApiTranscodeSessionsProgressGet(req, reply, { viewer
19
19
  // quality change the stream on screen is another session — a different one
20
20
  // for each viewer who changed.
21
21
  const consumerId = typeof req.query?.consumer === "string" ? req.query.consumer : "";
22
- const progress = await viewerRequests.getSessionProgress(sessionId, consumerId);
22
+ const stated = Number(req.query?.generation);
23
+ const generation = Number.isSafeInteger(stated) && stated >= 0 ? stated : Number.NaN;
24
+ const progress = await viewerRequests.getSessionProgress(sessionId, consumerId, generation);
25
+ if (progress?.superseded === true) {
26
+ return reply.code(409).send({ error: "Viewer action was superseded." });
27
+ }
23
28
  if (!progress) {
24
29
  return reply.code(404).send({ error: "Transcode session was not found." });
25
30
  }
26
31
 
27
32
  const responsePreparedAtProxyMs = performance.now();
28
33
  progress.snapshotTransport = {
34
+ clockEpoch: performance.timeOrigin,
35
+ snapshotClockEpoch: progress.playbackReadiness?.snapshotClockEpoch ?? null,
36
+ generation: Number.isSafeInteger(generation) ? generation : null,
29
37
  requestReceivedAtProxyMs,
30
38
  snapshotAtProxyMs: progress.playbackReadiness?.snapshotAtProxyMs ?? null,
31
39
  responsePreparedAtProxyMs
@@ -37,6 +37,8 @@
37
37
  * owns the output. This carries a decision out and reports what happened.
38
38
  */
39
39
 
40
+ import { performance } from "node:perf_hooks";
41
+
40
42
  import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
41
43
  import { classifyEncodeExit, ENCODE_EXIT } from "./encode-exit.js";
42
44
  import { workDeltaBetween } from "./encoder-readings.js";
@@ -228,6 +230,7 @@ export class EncodeRun {
228
230
  * @param {(args: string[]) => import("node:child_process").ChildProcess} params.spawn
229
231
  * @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
230
232
  * @param {() => number} [params.now]
233
+ * @param {() => number} [params.monotonicNow]
231
234
  * @param {(ended: RunEnded) => void} [params.onEnded]
232
235
  * @param {(reading: { speed: number, at: number, workSeconds: number, workingSeconds: number }) => void} [params.onSpeedMeasured] -
233
236
  * Told each time this run has measured its processing speed, once per
@@ -291,6 +294,7 @@ export class EncodeRun {
291
294
  indexOfName,
292
295
  startSeconds = 0,
293
296
  totalSeconds = null,
297
+ monotonicNow,
294
298
  because = "no reason was given"
295
299
  }) {
296
300
  this.address = address;
@@ -302,10 +306,11 @@ export class EncodeRun {
302
306
  this.spawnProcess = spawn;
303
307
  this.logger = logger;
304
308
  this.now = typeof now === "function" ? now : Date.now;
305
- this.progress = new RunProgress({ startSeconds, totalSeconds, now: this.now });
309
+ this.monotonicNow = typeof monotonicNow === "function" ? monotonicNow : () => performance.now();
310
+ this.progress = new RunProgress({ startSeconds, totalSeconds, now: this.now, monotonicNow: this.monotonicNow });
306
311
  /** Where this run's time went: its own work, or waiting, or stopped. */
307
312
  this.clock = new RunClock({ now: this.now });
308
- this.#pieceSample = { at: this.now(), producedSeconds: 0, workingMs: 0 };
313
+ this.#pieceSample = { at: this.now(), monotonicAt: this.monotonicNow(), producedSeconds: 0, workingMs: 0 };
309
314
  this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
310
315
  this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
311
316
  this.onProduced = typeof onProduced === "function" ? onProduced : () => {};
@@ -463,6 +468,7 @@ export class EncodeRun {
463
468
  this.#pieceSample = {
464
469
  ...this.#workSample,
465
470
  at: this.now(),
471
+ monotonicAt: this.monotonicNow(),
466
472
  workingMs: this.clock.workingMs()
467
473
  };
468
474
  }
@@ -600,6 +606,7 @@ export class EncodeRun {
600
606
  const at = this.now();
601
607
  this.#workSample = {
602
608
  at,
609
+ monotonicAt: this.progress.updatedAtMonotonic,
603
610
  producedSeconds: Math.max(0, this.progress.processedSeconds - this.progress.startPositionSeconds),
604
611
  workingMs: this.clock.workingMs(at)
605
612
  };
@@ -766,7 +773,10 @@ export class EncodeRun {
766
773
  }
767
774
  const work = workDeltaBetween(this.#pieceSample, sample);
768
775
  if (work !== null) {
769
- this.#speedReading = { speed: work.workSeconds / work.workingSeconds, at: sample.at, ...work };
776
+ this.#speedReading = { speed: work.workSeconds / work.workingSeconds, at: sample.at, ...work,
777
+ captureOwner: "proxy-encoder", captureClock: "performance", captureClockEpoch: performance.timeOrigin,
778
+ captureStartedAtMonotonic: this.#pieceSample.monotonicAt,
779
+ captureCompletedAtMonotonic: sample.monotonicAt };
770
780
  this.#pieceSample = sample;
771
781
  this.#speedMeasurementPending = false;
772
782
  this.onSpeedMeasured(this.speedReading);
@@ -1,3 +1,5 @@
1
+ import { performance } from "node:perf_hooks";
2
+
1
3
  /**
2
4
  * @file Progress reported by one encoder run, placed on the source timeline.
3
5
  *
@@ -22,15 +24,18 @@ function parseTimestamp(value) {
22
24
  }
23
25
 
24
26
  export class RunProgress {
25
- constructor({ startSeconds = 0, totalSeconds = null, now = Date.now } = {}) {
27
+ constructor({ startSeconds = 0, totalSeconds = null, now = Date.now, monotonicNow = () => performance.now() } = {}) {
26
28
  this.startPositionSeconds = Number.isFinite(startSeconds) ? Math.max(0, startSeconds) : 0;
27
29
  this.processedSeconds = this.startPositionSeconds;
28
30
  this.totalSeconds = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : null;
29
31
  this.percent = null;
30
32
  this.remainingSeconds = this.totalSeconds;
31
33
  this.updatedAt = now();
34
+ this.updatedAtMonotonic = monotonicNow();
35
+ this.clockEpoch = performance.timeOrigin;
32
36
  this.lastLoggedAt = 0;
33
37
  this.now = now;
38
+ this.monotonicNow = monotonicNow;
34
39
  this.#derive();
35
40
  }
36
41
 
@@ -42,6 +47,7 @@ export class RunProgress {
42
47
  this.processedSeconds = this.startPositionSeconds + Math.max(0, relative);
43
48
  }
44
49
  this.updatedAt = this.now();
50
+ this.updatedAtMonotonic = this.monotonicNow();
45
51
  this.#derive();
46
52
  return this.snapshot();
47
53
  }
@@ -61,7 +67,9 @@ export class RunProgress {
61
67
  totalSeconds: this.totalSeconds,
62
68
  percent: this.percent,
63
69
  remainingSeconds: this.remainingSeconds,
64
- updatedAt: this.updatedAt
70
+ updatedAt: this.updatedAt,
71
+ updatedAtMonotonic: this.updatedAtMonotonic,
72
+ clockEpoch: this.clockEpoch
65
73
  };
66
74
  }
67
75
 
@@ -77,3 +85,4 @@ export class RunProgress {
77
85
  this.remainingSeconds = Math.max(0, this.totalSeconds - this.processedSeconds);
78
86
  }
79
87
  }
88
+
@@ -14,7 +14,9 @@
14
14
  */
15
15
 
16
16
  export class SpeedReadings {
17
- /** @type {WeakMap<object, Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number }>>} */
17
+ /** @type {WeakMap<object, Array<{ speed: number, at: number, workSeconds?: number, workingSeconds?: number,
18
+ * captureOwner?: string, captureClock?: string, captureClockEpoch?: number,
19
+ * captureStartedAtMonotonic?: number, captureCompletedAtMonotonic?: number }>>} */
18
20
  #byOutput = new WeakMap();
19
21
  #keepMs;
20
22
 
@@ -25,7 +27,9 @@ export class SpeedReadings {
25
27
 
26
28
  /**
27
29
  * @param {object} output
28
- * @param {{ speed: number, at: number, workSeconds?: number, workingSeconds?: number } | null} reading
30
+ * @param {{ speed: number, at: number, workSeconds?: number, workingSeconds?: number,
31
+ * captureOwner?: string, captureClock?: string, captureClockEpoch?: number,
32
+ * captureStartedAtMonotonic?: number, captureCompletedAtMonotonic?: number } | null} reading
29
33
  */
30
34
  note(output, reading) {
31
35
  if (!output || !(reading?.speed > 0) || !Number.isFinite(reading.at)) return;
@@ -34,6 +38,10 @@ export class SpeedReadings {
34
38
  let place = readings.length;
35
39
  while (place > 0 && readings[place - 1].at > reading.at) place -= 1;
36
40
  const sample = { speed: reading.speed, at: reading.at };
41
+ for (const key of ["captureOwner", "captureClock", "captureClockEpoch", "captureStartedAtMonotonic",
42
+ "captureCompletedAtMonotonic"]) {
43
+ if (reading[key] !== undefined) sample[key] = reading[key];
44
+ }
37
45
  if (Number.isFinite(reading.workSeconds) && reading.workSeconds > 0 &&
38
46
  Number.isFinite(reading.workingSeconds) && reading.workingSeconds > 0) {
39
47
  Object.assign(sample, { workSeconds: reading.workSeconds, workingSeconds: reading.workingSeconds });
@@ -325,7 +325,7 @@ export class HostLoad {
325
325
  * What the swarm can give every torrent, in bytes per second: the median of
326
326
  * what it carried while our selection held no connection back, but never
327
327
  * less than the median of what it carried in all — the same choice the
328
- * start forecast makes for one source (`ViewerRequests` `sourceService`).
328
+ * start forecast makes for one source (`ViewerPlaybackState` `sourceService`).
329
329
  * Time our selection held a connection back measures our asking, not the
330
330
  * swarm, so holding the speculative levels back does not lower it
331
331
  * (torrent-tv/meta#180). Null before any interval was read.
@@ -0,0 +1,23 @@
1
+ /** Aggregates playback-forecast work for the existing once-a-minute log. */
2
+ export class ForecastDiagnostics {
3
+ #logger;
4
+ #costs = { since: Date.now(), polls: 0, ms: 0, maxMs: 0, times: new Map() };
5
+
6
+ constructor({ logger }) {
7
+ this.#logger = logger;
8
+ }
9
+
10
+ note(ms, reasons) {
11
+ const costs = this.#costs;
12
+ costs.polls += 1;
13
+ costs.ms += ms;
14
+ costs.maxMs = Math.max(costs.maxMs, ms);
15
+ for (const reason of reasons) costs.times.set(reason, (costs.times.get(reason) ?? 0) + 1);
16
+ const now = Date.now();
17
+ if (now - costs.since < 60_000) return;
18
+ this.#logger.info(`start forecasts in ${((now - costs.since) / 1000).toFixed(1)}s: ${costs.polls}, ` +
19
+ `${Math.round(costs.ms)}ms in all, ${(costs.ms / costs.polls).toFixed(1)}ms each, longest ${Math.round(costs.maxMs)}ms; ` +
20
+ `times of a track's pieces: ${[...costs.times].map(([reason, count]) => `${reason} ${count}`).join(", ")}`);
21
+ Object.assign(costs, { since: now, polls: 0, ms: 0, maxMs: 0, times: new Map() });
22
+ }
23
+ }
@@ -73,6 +73,7 @@ export class OutputLifecycle {
73
73
  return;
74
74
  }
75
75
  this.#host.invalidateWaits(session);
76
+ this.#host.forgetForecastOutput?.(session);
76
77
  this.#host.outputs.delete(sessionId);
77
78
  this.#host.outputTimes.logIndexAccuracy(session);
78
79
 
@@ -406,7 +407,9 @@ export class OutputLifecycle {
406
407
  }
407
408
  // Presence ends synchronously, before disposal waits for a child process.
408
409
  // Late reports and preparations must not rejoin this viewer meanwhile.
410
+ const viewer = this.#host.viewers.get(consumerId);
409
411
  const outputs = this.#host.viewers.hasGone(consumerId);
412
+ this.#host.viewerDeparted?.(viewer);
410
413
  for (const outputId of outputs) {
411
414
  await this.releaseSessionConsumer(outputId, consumerId, because);
412
415
  }
@@ -428,11 +431,14 @@ export class OutputLifecycle {
428
431
  if (!output) {
429
432
  return false;
430
433
  }
434
+ const viewer = this.#host.viewers.get(consumerId);
431
435
  this.#host.releasePlaybackStorageDemand?.(output.outputKey ?? "", consumerId);
432
436
  // Nothing to release in the encoding: it holds one map per output, built
433
437
  // from where the viewers are, and the map that arrives next simply does not
434
438
  // have this one in it. A name to release was the last place a viewer
435
439
  // appeared inside the encoding at all.
436
- return this.#host.viewers.leaves(output, consumerId);
440
+ const removed = this.#host.viewers.leaves(output, consumerId);
441
+ if (removed) this.#host.outputViewerDeparted?.(viewer, output);
442
+ return removed;
437
443
  }
438
444
  }