@torrent-tv/proxy 2.81.2 → 2.83.0

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.
@@ -53,6 +53,17 @@ export class EncodeOrchestrator {
53
53
  /** What a stop and a start have cost on this host. */
54
54
  #costs = new RunCosts();
55
55
 
56
+ /**
57
+ * What starting and stopping an encoder was measured to cost on this host,
58
+ * before any viewer existed. Told once; real runs replace it as they end.
59
+ *
60
+ * @param {{ firstByteWaitSec: number, killCostSec: number } | null} measured
61
+ * @returns {void}
62
+ */
63
+ noteStartupCosts(measured) {
64
+ this.#costs.noteStartup(measured);
65
+ }
66
+
56
67
  /** The last reason a budget was cut, so the same one is not said twice. */
57
68
  #lastBudgetReason = new Map();
58
69
 
@@ -72,10 +72,16 @@ export class PieceDiskStore {
72
72
 
73
73
  #evictions = 0;
74
74
 
75
+ /** How many were thrown away for being behind every reader. */
76
+ #behind = 0;
77
+
75
78
  #bytes = 0;
76
79
 
77
80
  #now;
78
81
 
82
+ /** Where the live readers stand, from whoever holds that fact. @type {() => number[]} */
83
+ #readHeads;
84
+
79
85
  /**
80
86
  * @param {object} params
81
87
  * @param {string} params.directory - Where this torrent's pieces live.
@@ -88,11 +94,12 @@ export class PieceDiskStore {
88
94
  * means nobody has said yet, and nothing is evicted until somebody does.
89
95
  * @param {() => number} [params.now]
90
96
  */
91
- constructor({ directory, name, chunkLength, allowanceBytes = null, now = Date.now }) {
97
+ constructor({ directory, name, chunkLength, allowanceBytes = null, now = Date.now, readHeads = () => [] }) {
92
98
  this.#directory = path.join(directory, name);
93
99
  this.#chunkLength = chunkLength;
94
100
  this.#allowanceBytes = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
95
101
  this.#now = now;
102
+ this.#readHeads = typeof readHeads === "function" ? readHeads : () => [];
96
103
  }
97
104
 
98
105
  /** Where this store's pieces live, for logging and cleanup. */
@@ -139,6 +146,45 @@ export class PieceDiskStore {
139
146
  return this.#stored.has(index);
140
147
  }
141
148
 
149
+ /**
150
+ * Throw away what no reader will ask for again, without waiting for the disk
151
+ * to be short.
152
+ *
153
+ * THE SECOND RULE, and it answers a different question from the ceiling.
154
+ * Material nobody needs should not sit on somebody's disk merely because
155
+ * there is room for it — the ceiling here is a share of what is free, and on
156
+ * a roomy host that is tens of gigabytes against a measured growth of 14 400
157
+ * MB in one viewing, so the ceiling alone never binds and nothing is ever
158
+ * removed until the torrent itself goes.
159
+ *
160
+ * A piece BEHIND every read head has been read and will not be read again
161
+ * unless somebody seeks back — and a seek back re-downloads it, which is the
162
+ * bargain this tier already makes when it drops a piece for room. Nothing is
163
+ * thrown away while any reader might still reach it.
164
+ *
165
+ * With no reader at all nothing is removed: a store between reads is not a
166
+ * store nobody wants, and the torrent going idle is what empties it whole.
167
+ *
168
+ * @param {number[]} readHeads - The first piece each live reader still wants.
169
+ * @returns {number} How many pieces were thrown away.
170
+ */
171
+ forgetBehind(readHeads) {
172
+ const heads = (readHeads ?? []).filter((at) => Number.isInteger(at));
173
+ if (heads.length === 0) {
174
+ return 0;
175
+ }
176
+ const earliest = Math.min(...heads);
177
+ let removed = 0;
178
+ for (const index of [...this.#stored.keys()]) {
179
+ if (index < earliest && !this.#reading.has(index)) {
180
+ this.forget(index);
181
+ this.#behind += 1;
182
+ removed += 1;
183
+ }
184
+ }
185
+ return removed;
186
+ }
187
+
142
188
  /**
143
189
  * Write a piece out, making room for it first.
144
190
  *
@@ -234,14 +280,15 @@ export class PieceDiskStore {
234
280
  /**
235
281
  * What it holds, what it may hold, and what it has had to throw away.
236
282
  *
237
- * @returns {{ pieces: number, bytes: number, allowanceBytes: number | null, evictions: number }}
283
+ * @returns {{ pieces: number, bytes: number, allowanceBytes: number | null, evictions: number, behind: number }}
238
284
  */
239
285
  stats() {
240
286
  return {
241
287
  pieces: this.#stored.size,
242
288
  bytes: this.#bytes,
243
289
  allowanceBytes: this.#allowanceBytes,
244
- evictions: this.#evictions
290
+ evictions: this.#evictions,
291
+ behind: this.#behind
245
292
  };
246
293
  }
247
294
 
@@ -349,14 +396,32 @@ export class PieceDiskStore {
349
396
  * @returns {number | null}
350
397
  */
351
398
  #leastRecentlyUsed(except) {
399
+ // WHERE THE READERS STAND DECIDES, and last use only settles ties.
400
+ //
401
+ // What lies behind every read head has been read and will not be read again
402
+ // unless somebody seeks back, so it goes before anything ahead of them,
403
+ // furthest behind first. It is the order the segments are given one layer
404
+ // up, and the order the priority map states, read from the other end.
405
+ // Without the heads there is nothing to order by and last use is all that
406
+ // is left — which is what this was, and what said nothing about what
407
+ // anybody is about to read.
408
+ const heads = this.#readHeads().filter((at) => Number.isInteger(at));
409
+ const earliest = heads.length > 0 ? Math.min(...heads) : null;
352
410
  let victim = null;
353
- let oldest = Number.POSITIVE_INFINITY;
411
+ let worst = null;
354
412
  for (const [index, at] of this.#touched) {
355
413
  if (index === except || this.#reading.has(index)) {
356
414
  continue;
357
415
  }
358
- if (at < oldest) {
359
- oldest = at;
416
+ const behind = earliest !== null && index < earliest;
417
+ const score = { behind, distance: behind ? earliest - index : 0, at };
418
+ if (
419
+ worst === null
420
+ || (score.behind && !worst.behind)
421
+ || (score.behind === worst.behind && score.distance > worst.distance)
422
+ || (score.behind === worst.behind && score.distance === worst.distance && score.at < worst.at)
423
+ ) {
424
+ worst = score;
360
425
  victim = index;
361
426
  }
362
427
  }
@@ -258,6 +258,23 @@ export class PieceLru {
258
258
  return want;
259
259
  }
260
260
 
261
+ /**
262
+ * Where the live readers stand, as the first piece each of them still wants.
263
+ *
264
+ * A declared range begins at the piece its reader is about to read, so the
265
+ * earliest of those beginnings is the point behind which nothing will be
266
+ * asked for again unless somebody seeks back. That is what makes a spilled
267
+ * piece disposable without waiting for the disk to be short: a piece behind
268
+ * every reader has been read and will not be read again.
269
+ *
270
+ * @returns {number[]} One number per reader, unsorted. Empty when nobody has
271
+ * declared anything, which is a stronger statement than any position — no
272
+ * piece of this file is spoken for at all.
273
+ */
274
+ readHeads() {
275
+ return [...this.#protected.values()].map((range) => range.from);
276
+ }
277
+
261
278
  /**
262
279
  * How many pieces the live readers between them are asking to keep, against
263
280
  * how many this store may hold.