@torrent-tv/proxy 2.82.0 → 2.83.1

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CLAUDE.md +11 -0
  3. package/docs/disk-architecture.md +161 -0
  4. package/docs/encode-architecture.md +36 -7
  5. package/package.json +1 -1
  6. package/routes/api/sources/stats/get.js +11 -2
  7. package/routes/stream/get.js +74 -3
  8. package/services/delivery-probe.js +248 -43
  9. package/services/disk/keep.js +48 -0
  10. package/services/disk/returns.js +103 -0
  11. package/services/download/SwarmSelection.js +5 -5
  12. package/services/download/registry.js +20 -0
  13. package/services/encode/EncodeRun.js +1 -0
  14. package/services/encode/Encoder.js +15 -0
  15. package/services/encode/QsvEncoder.js +5 -0
  16. package/services/encode/SegmentStore.js +14 -0
  17. package/services/encode/VaapiEncoder.js +5 -0
  18. package/services/encode/encode-exit.js +17 -0
  19. package/services/encode/start-stop-cost.js +6 -2
  20. package/services/files/CompletedFiles.js +276 -0
  21. package/services/files/piece-from-whole-file.js +118 -0
  22. package/services/hls-session-manager.js +17 -1
  23. package/services/hwaccel.js +4 -0
  24. package/services/output/cut-grid.js +13 -3
  25. package/services/piece-store/piece-disk-store.js +143 -8
  26. package/services/piece-store/piece-lru.js +17 -0
  27. package/services/piece-store/shared-piece-store.js +1803 -1549
  28. package/services/torrent-pool.js +246 -26
  29. package/services/torrent-worker/client.js +21 -0
  30. package/services/torrent-worker/protocol.js +9 -1
  31. package/services/torrent-worker/worker.js +183 -2
  32. package/test/completed-files.test.js +115 -0
  33. package/test/cuts-follow-published-grid.test.js +35 -0
  34. package/test/delivery-probe.test.js +114 -1
  35. package/test/encode-exit.test.js +18 -0
  36. package/test/keeping-period.test.js +83 -0
  37. package/test/piece-disk-store.test.js +114 -0
  38. package/test/piece-from-whole-file.test.js +129 -0
  39. package/test/piece-store-eviction.test.js +28 -15
  40. package/test/piece-store-never-refuses.test.js +153 -0
  41. package/test/piece-store-reservations.test.js +16 -3
  42. package/test/probe-wedge-certainty.test.js +3 -3
  43. package/test/shared-piece-store.test.js +27 -13
  44. package/test/stream-route.test.js +41 -0
  45. package/test/swarm-follows-readers.test.js +126 -0
  46. package/test/swarm-reach.test.js +5 -0
  47. package/test/upload-hurry.test.js +27 -0
@@ -31,6 +31,7 @@
31
31
  */
32
32
 
33
33
  import fs from "node:fs/promises";
34
+ import { readdirSync, statSync } from "node:fs";
34
35
  import path from "node:path";
35
36
 
36
37
  /**
@@ -72,10 +73,16 @@ export class PieceDiskStore {
72
73
 
73
74
  #evictions = 0;
74
75
 
76
+ /** How many were thrown away for being behind every reader. */
77
+ #behind = 0;
78
+
75
79
  #bytes = 0;
76
80
 
77
81
  #now;
78
82
 
83
+ /** Where the live readers stand, from whoever holds that fact. @type {() => number[]} */
84
+ #readHeads;
85
+
79
86
  /**
80
87
  * @param {object} params
81
88
  * @param {string} params.directory - Where this torrent's pieces live.
@@ -88,11 +95,65 @@ export class PieceDiskStore {
88
95
  * means nobody has said yet, and nothing is evicted until somebody does.
89
96
  * @param {() => number} [params.now]
90
97
  */
91
- constructor({ directory, name, chunkLength, allowanceBytes = null, now = Date.now }) {
98
+ constructor({ directory, name, chunkLength, allowanceBytes = null, now = Date.now, readHeads = () => [] }) {
92
99
  this.#directory = path.join(directory, name);
93
100
  this.#chunkLength = chunkLength;
94
101
  this.#allowanceBytes = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
95
102
  this.#now = now;
103
+ this.#readHeads = typeof readHeads === "function" ? readHeads : () => [];
104
+ this.#adoptWhatIsAlreadyHere();
105
+ }
106
+
107
+ /**
108
+ * Take up the pieces a previous life of this torrent left in this directory.
109
+ *
110
+ * The directory is the torrent's own, so what is in it belongs to it — and
111
+ * without this, nothing ever reads those files again: a torrent that is torn
112
+ * down and added back gets a store whose index starts empty, answers "not on
113
+ * disk" for every piece it in fact has, and downloads the film a second time
114
+ * while the first copy sits beside it. That is what a torrent destroyed by an
115
+ * error left behind until 2026-09-11, and what the pool's own restart leaves
116
+ * behind every time.
117
+ *
118
+ * Read once, synchronously, because `has()` is answered synchronously and the
119
+ * torrent asks it immediately — a piece reported missing while a scan is
120
+ * still running is a piece fetched again. One directory listing per torrent.
121
+ *
122
+ * Correctness is not taken on trust: the torrent hashes every piece it means
123
+ * to use, so a file here that does not match is refused by the layer above
124
+ * and downloaded again.
125
+ *
126
+ * @returns {void}
127
+ */
128
+ #adoptWhatIsAlreadyHere() {
129
+ let entries = [];
130
+ try {
131
+ entries = readdirSync(this.#directory, { withFileTypes: true });
132
+ } catch {
133
+ // No directory yet: this torrent is new here, which is the ordinary case.
134
+ return;
135
+ }
136
+ const born = this.#now();
137
+ for (const entry of entries) {
138
+ if (!entry.isFile() || !entry.name.endsWith(".piece")) {
139
+ continue;
140
+ }
141
+ const index = Number.parseInt(entry.name.slice(0, -".piece".length), 10);
142
+ if (!Number.isInteger(index) || index < 0) {
143
+ continue;
144
+ }
145
+ try {
146
+ const { size } = statSync(path.join(this.#directory, entry.name));
147
+ if (size <= 0) {
148
+ continue;
149
+ }
150
+ this.#stored.set(index, size);
151
+ this.#touched.set(index, born);
152
+ this.#bytes += size;
153
+ } catch {
154
+ // Gone between the listing and the reading: not ours to worry about.
155
+ }
156
+ }
96
157
  }
97
158
 
98
159
  /** Where this store's pieces live, for logging and cleanup. */
@@ -100,6 +161,15 @@ export class PieceDiskStore {
100
161
  return this.#directory;
101
162
  }
102
163
 
164
+ /**
165
+ * Every piece this tier holds, as numbers.
166
+ *
167
+ * @returns {number[]}
168
+ */
169
+ indexes() {
170
+ return [...this.#stored.keys()];
171
+ }
172
+
103
173
  /** How many pieces are on disk. */
104
174
  get size() {
105
175
  return this.#stored.size;
@@ -139,6 +209,45 @@ export class PieceDiskStore {
139
209
  return this.#stored.has(index);
140
210
  }
141
211
 
212
+ /**
213
+ * Throw away what no reader will ask for again, without waiting for the disk
214
+ * to be short.
215
+ *
216
+ * THE SECOND RULE, and it answers a different question from the ceiling.
217
+ * Material nobody needs should not sit on somebody's disk merely because
218
+ * there is room for it — the ceiling here is a share of what is free, and on
219
+ * a roomy host that is tens of gigabytes against a measured growth of 14 400
220
+ * MB in one viewing, so the ceiling alone never binds and nothing is ever
221
+ * removed until the torrent itself goes.
222
+ *
223
+ * A piece BEHIND every read head has been read and will not be read again
224
+ * unless somebody seeks back — and a seek back re-downloads it, which is the
225
+ * bargain this tier already makes when it drops a piece for room. Nothing is
226
+ * thrown away while any reader might still reach it.
227
+ *
228
+ * With no reader at all nothing is removed: a store between reads is not a
229
+ * store nobody wants, and the torrent going idle is what empties it whole.
230
+ *
231
+ * @param {number[]} readHeads - The first piece each live reader still wants.
232
+ * @returns {number} How many pieces were thrown away.
233
+ */
234
+ forgetBehind(readHeads) {
235
+ const heads = (readHeads ?? []).filter((at) => Number.isInteger(at));
236
+ if (heads.length === 0) {
237
+ return 0;
238
+ }
239
+ const earliest = Math.min(...heads);
240
+ let removed = 0;
241
+ for (const index of [...this.#stored.keys()]) {
242
+ if (index < earliest && !this.#reading.has(index)) {
243
+ this.forget(index);
244
+ this.#behind += 1;
245
+ removed += 1;
246
+ }
247
+ }
248
+ return removed;
249
+ }
250
+
142
251
  /**
143
252
  * Write a piece out, making room for it first.
144
253
  *
@@ -167,11 +276,18 @@ export class PieceDiskStore {
167
276
  /**
168
277
  * Read a piece back into a buffer the caller already owns.
169
278
  *
279
+ * PART of a piece, when the caller asks for one. A peer asks for 16 KB at a
280
+ * time and a piece here is megabytes, so reading the whole of it to answer
281
+ * one request is the difference between 16 KB and 4 MB off the disk — field
282
+ * 2026-09-11: 63 416 reads of which 21.6 % came from memory, 49 696 pieces
283
+ * revived whole, to serve an upload capped at 512 KB/s.
284
+ *
170
285
  * @param {number} index
171
286
  * @param {Uint8Array} target - Destination; its length is what gets read.
287
+ * @param {number} [at] - Offset within the piece to start at.
172
288
  * @returns {Promise<number>} Bytes read.
173
289
  */
174
- async read(index, target) {
290
+ async read(index, target, at = 0) {
175
291
  if (!this.#stored.has(index)) {
176
292
  throw new Error(`Piece ${index} is not on disk.`);
177
293
  }
@@ -183,7 +299,7 @@ export class PieceDiskStore {
183
299
  let handle = null;
184
300
  try {
185
301
  handle = await fs.open(this.#pathOf(index), "r");
186
- const { bytesRead } = await handle.read(target, 0, target.length, 0);
302
+ const { bytesRead } = await handle.read(target, 0, target.length, Math.max(0, at));
187
303
  this.#touched.set(index, this.#now());
188
304
  return bytesRead;
189
305
  } finally {
@@ -234,14 +350,15 @@ export class PieceDiskStore {
234
350
  /**
235
351
  * What it holds, what it may hold, and what it has had to throw away.
236
352
  *
237
- * @returns {{ pieces: number, bytes: number, allowanceBytes: number | null, evictions: number }}
353
+ * @returns {{ pieces: number, bytes: number, allowanceBytes: number | null, evictions: number, behind: number }}
238
354
  */
239
355
  stats() {
240
356
  return {
241
357
  pieces: this.#stored.size,
242
358
  bytes: this.#bytes,
243
359
  allowanceBytes: this.#allowanceBytes,
244
- evictions: this.#evictions
360
+ evictions: this.#evictions,
361
+ behind: this.#behind
245
362
  };
246
363
  }
247
364
 
@@ -349,14 +466,32 @@ export class PieceDiskStore {
349
466
  * @returns {number | null}
350
467
  */
351
468
  #leastRecentlyUsed(except) {
469
+ // WHERE THE READERS STAND DECIDES, and last use only settles ties.
470
+ //
471
+ // What lies behind every read head has been read and will not be read again
472
+ // unless somebody seeks back, so it goes before anything ahead of them,
473
+ // furthest behind first. It is the order the segments are given one layer
474
+ // up, and the order the priority map states, read from the other end.
475
+ // Without the heads there is nothing to order by and last use is all that
476
+ // is left — which is what this was, and what said nothing about what
477
+ // anybody is about to read.
478
+ const heads = this.#readHeads().filter((at) => Number.isInteger(at));
479
+ const earliest = heads.length > 0 ? Math.min(...heads) : null;
352
480
  let victim = null;
353
- let oldest = Number.POSITIVE_INFINITY;
481
+ let worst = null;
354
482
  for (const [index, at] of this.#touched) {
355
483
  if (index === except || this.#reading.has(index)) {
356
484
  continue;
357
485
  }
358
- if (at < oldest) {
359
- oldest = at;
486
+ const behind = earliest !== null && index < earliest;
487
+ const score = { behind, distance: behind ? earliest - index : 0, at };
488
+ if (
489
+ worst === null
490
+ || (score.behind && !worst.behind)
491
+ || (score.behind === worst.behind && score.distance > worst.distance)
492
+ || (score.behind === worst.behind && score.distance === worst.distance && score.at < worst.at)
493
+ ) {
494
+ worst = score;
360
495
  victim = index;
361
496
  }
362
497
  }
@@ -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.