@torrent-tv/proxy 2.98.16 → 2.98.18

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.98.16",
3
+ "version": "2.98.18",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -1319,8 +1319,13 @@ export class EncodeRuns {
1319
1319
  /**
1320
1320
  * How long this run took to open its original input, for sizing the next
1321
1321
  * stretch: from its spawn to the name of its first closed piece, less what
1322
- * encoding that piece costs at the run's speed — its own where it measured
1323
- * one, else the output's. Nothing is noted where either is unknown.
1322
+ * encoding that piece costs at a measured speed — the run's own where it
1323
+ * closed two pieces, else the output's newest reading, which is taken
1324
+ * between two closed pieces of a run too. Never a predicted speed: the
1325
+ * output's starting figure, scaled by a machine share that missed short
1326
+ * runs, read 0.08x on Home Assistant and recorded opens of 0.00 s (r41,
1327
+ * torrent-tv/meta#178). Nothing is noted where no speed was measured or the
1328
+ * rest is negative, and the open estimated before stands.
1324
1329
  *
1325
1330
  * @param {object} session
1326
1331
  * @param {import("./EncodeRun.js").EncodeRun} run
@@ -1330,14 +1335,21 @@ export class EncodeRuns {
1330
1335
  #noteOpen(session, run, ended) {
1331
1336
  const grid = session.timeline?.published ?? session.timeline?.boundaries;
1332
1337
  const pieceSeconds = Number(grid?.[run.from + 1]) - Number(grid?.[run.from]);
1333
- const speed = run.speedX > 0 ? run.speedX : this.#host.encodeOrchestrator.speedOn?.(session.outputKey) ?? 0;
1338
+ const speed = run.speedX > 0 ? run.speedX : this.#speedReadings.windowOf(session).at(-1)?.speed ?? 0;
1334
1339
  // FFmpeg's reads may have waited for the stretch being copied; that wait
1335
1340
  // is the copy's, and the stretch is sized from the open (`run-open.js`).
1336
1341
  const open = runOpenOf({
1337
1342
  firstNamedMs: ended.firstNamedMs, firstNamedWallMs: ended.firstNamedWallMs, startedAt: run.startedAt,
1338
1343
  waitedBefore: run.originalInput?.waitedBefore, pieceSeconds, speed
1339
1344
  });
1340
- if (!open) return;
1345
+ if (!open) {
1346
+ if (Number.isFinite(ended.firstNamedMs) && pieceSeconds > 0) {
1347
+ this.#host.logger.info(`encode input of run #${run.from}..#${run.to} on ${session.outputKey}: open not measured ` +
1348
+ `(first piece named at ${ended.firstNamedMs}ms, ${pieceSeconds.toFixed(3)}s of film, ` +
1349
+ `${speed > 0 ? `at a measured ${speed.toFixed(2)}x the piece takes longer than that` : "no speed measured between two closed pieces"})`);
1350
+ }
1351
+ return;
1352
+ }
1341
1353
  const { openSeconds, waitedMs } = open;
1342
1354
  this.#host.encodeOrchestrator.noteOpen?.(session.outputKey ?? "", openSeconds);
1343
1355
  this.#host.logger.info(`encode input of run #${run.from}..#${run.to} on ${session.outputKey}: opened in ` +
@@ -8,6 +8,14 @@
8
8
  * wall clock, so they are taken up to the wall-clock moment the first piece
9
9
  * was named — the run's start plus its time to that name, not that time alone,
10
10
  * which as a moment lay before every wait and counted none of them.
11
+ *
12
+ * THE SPEED IS A MEASURED ONE, between two closed pieces, and a negative rest
13
+ * is no open at all: on Home Assistant runs of one piece took 6.0-7.5 s to it
14
+ * for 4 s of film, were priced at a speed of 0.08x that no run had measured,
15
+ * and recorded opens of 0.00 s; the next inputs were sized from them to one
16
+ * piece, and the runs stayed one piece each (r41, torrent-tv/meta#178). A rest
17
+ * below zero says the speed does not describe that piece, and nothing is
18
+ * recorded: the open estimated before stands.
11
19
  */
12
20
 
13
21
  /**
@@ -18,12 +26,14 @@
18
26
  * @param {((until: number) => number) | null | undefined} params.waitedBefore - The input's wall-clock waits for its copy before `until`, ms.
19
27
  * @param {number} params.pieceSeconds - The first piece's film.
20
28
  * @param {number} params.speed - Film seconds per second; 0 when not known.
21
- * @returns {{ openSeconds: number, waitedMs: number } | null} Null where any term is unknown.
29
+ * @returns {{ openSeconds: number, waitedMs: number } | null} Null where any term is
30
+ * unknown, or where the piece's encoding at that speed exceeds the time to it.
22
31
  */
23
32
  export function runOpenOf({ firstNamedMs, firstNamedWallMs, startedAt, waitedBefore, pieceSeconds, speed }) {
24
33
  if (!Number.isFinite(firstNamedMs) || !(pieceSeconds > 0) || !(speed > 0)) return null;
25
34
  const waitedMs = Number.isFinite(firstNamedWallMs) && startedAt > 0 && typeof waitedBefore === "function"
26
35
  ? waitedBefore(startedAt + firstNamedWallMs)
27
36
  : 0;
28
- return { waitedMs, openSeconds: Math.max(0, (firstNamedMs - waitedMs) / 1000 - pieceSeconds / speed) };
37
+ const openSeconds = (firstNamedMs - waitedMs) / 1000 - pieceSeconds / speed;
38
+ return openSeconds >= 0 ? { waitedMs, openSeconds } : null;
29
39
  }
@@ -1545,7 +1545,13 @@ export class TorrentPool {
1545
1545
  wire.request = function (index, offset, length, callback) {
1546
1546
  const queued = this.requests.length;
1547
1547
  const answer = request.call(this, index, offset, length, callback);
1548
- if (this.requests.length > queued) timings.asked(index, offset, peer, Date.now());
1548
+ if (this.requests.length > queued) {
1549
+ const now = Date.now();
1550
+ timings.asked(index, offset, peer, now);
1551
+ // When this wire was asked for the block: how long it has held it is
1552
+ // what decides whether a second peer is asked (`duplicateTailFor`).
1553
+ this.requests.at(-1).askedAt = now;
1554
+ }
1549
1555
  return answer;
1550
1556
  };
1551
1557
  wire.on("piece", (index, offset) => timings.arrived(index, offset, peer, Date.now()));
@@ -264,25 +264,42 @@ export function describePieceTail(torrent, pieceIndex) {
264
264
  * @param {object} torrent
265
265
  * @param {number} pieceIndex
266
266
  * @param {object} [options]
267
+ * A BLOCK IS ASKED AGAIN ONLY ONCE IT IS LATE. A short tail is not by itself a
268
+ * delay: copied as soon as the tail was short, 12-16 blocks of nearly every
269
+ * piece were asked twice and 82 of 215 copies arrived after their piece was
270
+ * complete (r41, Home Assistant, torrent-tv/meta#178). So a block is asked of a
271
+ * second peer once its request to the first has been out `minAgeMs` — by
272
+ * default the second WebTorrent sizes a wire's queue to fill
273
+ * (`PIPELINE_MAX_DURATION`), a starting point to be checked in the field — and
274
+ * the earliest moment a held block becomes late is returned, so whoever calls
275
+ * this can look again then.
276
+ *
277
+ * @param {object} torrent
278
+ * @param {number} pieceIndex
279
+ * @param {object} [options]
267
280
  * @param {number} [options.maxCopies] - How many wires a missing block may be
268
281
  * in flight on at once: the first request and one copy beside it.
269
- * @returns {{ duplicated: number, missing: number, wires: number, unplaced: number }}
282
+ * @param {number} [options.minAgeMs] - How long the first request must have been out.
283
+ * @param {number} [options.now] - Wall clock, ms.
284
+ * @returns {{ duplicated: number, missing: number, wires: number, unplaced: number, dueAt: number | null }}
270
285
  * `duplicated` counts requests the library actually placed for a block that
271
- * was already outstanding elsewhere; `unplaced` the blocks in flight on one
272
- * wire alone that no other wire could be asked for now.
286
+ * was already outstanding elsewhere; `unplaced` the late blocks in flight on
287
+ * one wire alone that no other wire could be asked for now; `dueAt` when the
288
+ * first block held by one wire and not yet late becomes late, or null.
273
289
  */
274
- export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2 } = {}) {
290
+ export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2, minAgeMs = 1000, now = Date.now() } = {}) {
275
291
  const piece = torrent?.pieces?.[pieceIndex];
276
292
  const buffer = Array.isArray(piece?._buffer) ? piece._buffer : null;
277
293
  if (!piece || !buffer || typeof piece.cancel !== "function" || !canPlaceRequests(torrent)) {
278
- return { duplicated: 0, missing: 0, wires: 0, unplaced: 0 };
294
+ return { duplicated: 0, missing: 0, wires: 0, unplaced: 0, dueAt: null };
279
295
  }
280
296
  const chunks = Number.isFinite(piece._chunks) ? piece._chunks : buffer.length;
281
297
  // Most calls are for a piece still arriving in bulk; its own count says so
282
298
  // without walking its blocks or every wire's queue.
283
299
  if (Number.isFinite(piece._buffered) && chunks - piece._buffered > SHORT_TAIL_BLOCKS) {
284
- return { duplicated: 0, missing: chunks - piece._buffered, wires: 0, unplaced: 0 };
300
+ return { duplicated: 0, missing: chunks - piece._buffered, wires: 0, unplaced: 0, dueAt: null };
285
301
  }
302
+
286
303
  const missing = [];
287
304
  for (let index = 0; index < chunks; index += 1) {
288
305
  if (!buffer[index]) {
@@ -291,7 +308,7 @@ export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2 } = {}) {
291
308
  }
292
309
  const candidates = wiresForPiece(torrent, pieceIndex);
293
310
  if (missing.length === 0 || candidates.length === 0) {
294
- return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0 };
311
+ return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0, dueAt: null };
295
312
  }
296
313
 
297
314
  // A tail this short is what a blocked read is actually waiting on, and a
@@ -300,26 +317,34 @@ export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2 } = {}) {
300
317
  // bytes that are already coming — measured 2026-08-19, the tails a reader
301
318
  // waits on are 2 to 14 blocks of 512.
302
319
  if (missing.length > SHORT_TAIL_BLOCKS) {
303
- return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0 };
320
+ return { duplicated: 0, missing: missing.length, wires: candidates.length, unplaced: 0, dueAt: null };
304
321
  }
305
322
 
306
- // Which wires each missing block is in flight on, read from the wires' own
307
- // queues: the library keeps a block's reservation for one wire only, and a
308
- // copy asked here is visible nowhere else.
309
- const holders = new Map(missing.map(block => [block, new Set()]));
323
+ // Which wires each missing block is in flight on, and since when, read from
324
+ // the wires' own queues: the library keeps a block's reservation for one
325
+ // wire only, and a copy asked here is visible nowhere else. A request with no
326
+ // time of its own was asked before anybody watched, and is old.
327
+ const holders = new Map(missing.map(block => [block, { wires: new Set(), since: Number.POSITIVE_INFINITY }]));
310
328
  for (const wire of Array.isArray(torrent.wires) ? torrent.wires : []) {
311
329
  for (const request of Array.isArray(wire?.requests) ? wire.requests : []) {
312
330
  if (request?.piece !== pieceIndex) continue;
313
- holders.get(Math.floor(request.offset / BLOCK_BYTES))?.add(wire);
331
+ const holding = holders.get(Math.floor(request.offset / BLOCK_BYTES));
332
+ if (!holding) continue;
333
+ holding.wires.add(wire);
334
+ holding.since = Math.min(holding.since, Number.isFinite(request.askedAt) ? request.askedAt : Number.NEGATIVE_INFINITY);
314
335
  }
315
336
  }
316
337
 
317
- let duplicated = 0, unplaced = 0;
338
+ let duplicated = 0, unplaced = 0, dueAt = null;
318
339
  for (const block of missing) {
319
- const held = holders.get(block);
340
+ const { wires: held, since } = holders.get(block);
320
341
  // Not in flight anywhere: the library's own picker asks for it; a block
321
342
  // nobody has been asked for is not a tail held by a slow peer.
322
343
  if (held.size === 0 || held.size >= maxCopies) continue;
344
+ if (now - since < minAgeMs) {
345
+ dueAt = Math.min(dueAt ?? Number.POSITIVE_INFINITY, since + minAgeMs);
346
+ continue;
347
+ }
323
348
  let placed = false;
324
349
  for (const wire of candidates) {
325
350
  if (held.has(wire)) continue;
@@ -343,7 +368,7 @@ export function duplicateTailFor(torrent, pieceIndex, { maxCopies = 2 } = {}) {
343
368
  }
344
369
  if (!placed) unplaced += 1;
345
370
  }
346
- return { duplicated, missing: missing.length, wires: candidates.length, unplaced };
371
+ return { duplicated, missing: missing.length, wires: candidates.length, unplaced, dueAt };
347
372
  }
348
373
 
349
374
  /**
@@ -18,10 +18,12 @@
18
18
  * peer becomes able or unable to give the piece; so it is looked at on those —
19
19
  * a block arriving, a wire unchoking, a peer stating a piece, a wire joining
20
20
  * or closing, a piece verified, a new map. Several in one turn of the event
21
- * loop are one look. A tail no other wire could be asked for now is looked at
22
- * once more after the time a wire's queue is sized to empty in — WebTorrent
23
- * sizes it to one second of the wire's speed (`PIPELINE_MAX_DURATION`) — in
24
- * case nothing else happens in between.
21
+ * loop are one look. A block is asked again only once its request has been out
22
+ * a wire's queue time (`duplicateTailFor`), so a block not yet late is looked
23
+ * at again when it becomes late, and a late one no other wire could take is
24
+ * looked at again a queue time later — once, in case nothing else happens in
25
+ * between. WebTorrent sizes a wire's queue to one second of its speed
26
+ * (`PIPELINE_MAX_DURATION`).
25
27
  */
26
28
 
27
29
  import { duplicateTailFor } from "./fastest-wires.js";
@@ -41,12 +43,15 @@ export class TailRepeat {
41
43
  #clear;
42
44
  #scheduled = false;
43
45
  #timer = null;
46
+ /** When the armed look runs, wall clock ms. */
47
+ #timerAt = Number.POSITIVE_INFINITY;
44
48
  #disposed = false;
45
49
  /** @type {Map<object, { onBlock: (index: number) => void, onPeer: () => void }>} */
46
50
  #wires = new Map();
47
51
  #onWire;
48
52
  #onPoke;
49
53
  #log;
54
+ #now;
50
55
  /**
51
56
  * Grow-only, for the log: block copies asked, looks taken, and blocks that
52
57
  * arrived after their piece was complete — what the copies cost.
@@ -62,9 +67,11 @@ export class TailRepeat {
62
67
  * @param {(work: () => void, ms: number) => unknown} [params.wake]
63
68
  * @param {(handle: unknown) => void} [params.clear]
64
69
  * @param {(line: string) => void} [params.log]
70
+ * @param {() => number} [params.now]
65
71
  */
66
- constructor({ torrent, requiredPieces, duplicate = duplicateTailFor, schedule = setImmediate, wake = setTimeout, clear = clearTimeout, log = null }) {
72
+ constructor({ torrent, requiredPieces, duplicate = duplicateTailFor, schedule = setImmediate, wake = setTimeout, clear = clearTimeout, log = null, now = Date.now }) {
67
73
  this.#log = log;
74
+ this.#now = now;
68
75
  this.#torrent = torrent;
69
76
  this.#requiredPieces = requiredPieces;
70
77
  this.#duplicate = duplicate;
@@ -95,6 +102,19 @@ export class TailRepeat {
95
102
  this.#timer = null;
96
103
  }
97
104
 
105
+ /** One look armed at a time, at the earliest moment asked for. */
106
+ #lookAgainAt(at, now) {
107
+ if (!Number.isFinite(at) || at >= this.#timerAt) return;
108
+ if (this.#timer !== null) this.#clear(this.#timer);
109
+ this.#timerAt = at;
110
+ this.#timer = this.#wake(() => {
111
+ this.#timer = null;
112
+ this.#timerAt = Number.POSITIVE_INFINITY;
113
+ this.poke();
114
+ }, Math.max(0, at - now));
115
+ this.#timer?.unref?.();
116
+ }
117
+
98
118
  #watch(wire) {
99
119
  if (!wire || this.#wires.has(wire) || typeof wire.on !== "function") return;
100
120
  const listeners = {
@@ -128,14 +148,16 @@ export class TailRepeat {
128
148
  this.#scheduled = false;
129
149
  if (this.#disposed) return;
130
150
  this.totals.looks += 1;
131
- let unplaced = 0;
151
+ const now = this.#now();
152
+ let unplaced = 0, dueAt = Number.POSITIVE_INFINITY;
132
153
  const asked = [];
133
154
  for (const { from, to } of this.#requiredPieces()) {
134
155
  for (let index = from; index <= to; index += 1) {
135
156
  if (this.#torrent.bitfield?.get?.(index)) continue;
136
- const result = this.#duplicate(this.#torrent, index);
157
+ const result = this.#duplicate(this.#torrent, index, { minAgeMs: WIRE_QUEUE_SECONDS * 1000, now });
137
158
  this.totals.duplicated += result.duplicated;
138
159
  unplaced += result.unplaced;
160
+ if (Number.isFinite(result.dueAt)) dueAt = Math.min(dueAt, result.dueAt);
139
161
  if (result.duplicated > 0) asked.push(`${index}:${result.duplicated}/${result.missing}`);
140
162
  }
141
163
  }
@@ -143,12 +165,7 @@ export class TailRepeat {
143
165
  this.#log?.(`tail: asked a second peer for the last blocks of piece:blocks/missing ${asked.join(" ")}; ` +
144
166
  `${this.totals.duplicated} block copies asked so far, ${this.totals.lateBlocks} block(s) arrived after their piece was complete`);
145
167
  }
146
- if (unplaced > 0 && this.#timer === null) {
147
- this.#timer = this.#wake(() => {
148
- this.#timer = null;
149
- this.poke();
150
- }, WIRE_QUEUE_SECONDS * 1000);
151
- this.#timer?.unref?.();
152
- }
168
+ if (unplaced > 0) dueAt = Math.min(dueAt, now + WIRE_QUEUE_SECONDS * 1000);
169
+ this.#lookAgainAt(dueAt, now);
153
170
  }
154
171
  }