@torrent-tv/proxy 2.101.22 → 2.101.24

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.101.22",
3
+ "version": "2.101.24",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -219,7 +219,7 @@ export class OutputLifecycle {
219
219
  const maxBytes = this.#host.machineBudget.segmentBytes();
220
220
  const inventory = this.#host.segmentStore.inventory();
221
221
  let held = inventory.reduce((bytes, output) => bytes + output.bytes, 0);
222
- if (!Number.isFinite(maxBytes) || maxBytes <= 0 || held <= maxBytes) return;
222
+ if (!Number.isFinite(maxBytes) || maxBytes < 0 || held <= maxBytes) return;
223
223
  const candidates = leastNeededSegments(inventory.map(output => ({
224
224
  ...output,
225
225
  positions: this.#host.viewerSegmentsOn(output.key),
@@ -24,8 +24,10 @@
24
24
  */
25
25
 
26
26
  import fs from "node:fs/promises";
27
+ import { randomUUID } from "node:crypto";
27
28
  import os from "node:os";
28
29
  import path from "node:path";
30
+ import { IDLE_KEEP_MS } from "../keep.js";
29
31
 
30
32
  /**
31
33
  * Where whole files live.
@@ -43,6 +45,17 @@ export function completedFilesRoot() {
43
45
  /** How a removal is asked for; Windows needs the retries, POSIX ignores them. */
44
46
  const REMOVAL = { force: true, maxRetries: 10, retryDelay: 20 };
45
47
 
48
+ /** Whether another live process may still own an interrupted-write file. */
49
+ function writerIsAlive(pid) {
50
+ if (!Number.isSafeInteger(pid) || pid < 1) return false;
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ } catch (error) {
55
+ return error?.code === "EPERM";
56
+ }
57
+ }
58
+
46
59
  /**
47
60
  * One directory of whole files, keyed by the torrent they came from.
48
61
  */
@@ -61,6 +74,11 @@ export class CompletedFiles {
61
74
  #readers = new Map();
62
75
  #removeAfterRead = new Set();
63
76
  #removing = new Set();
77
+ /** Last access time for idle expiry. @type {Map<string, number>} */
78
+ #lastUsedAt = new Map();
79
+ /** @type {() => number} */
80
+ #now;
81
+ #idleTtlMs;
64
82
 
65
83
  /**
66
84
  * What this store may hold, handed to it by the owner of the disk.
@@ -88,9 +106,13 @@ export class CompletedFiles {
88
106
  * @param {object} params
89
107
  * @param {string} params.root - Where whole files live. Outside any torrent's
90
108
  * own store directory, which is removed with the torrent.
109
+ * @param {number} [params.idleTtlMs] - How long an unread file may remain.
110
+ * @param {() => number} [params.now]
91
111
  */
92
- constructor({ root }) {
112
+ constructor({ root, idleTtlMs = IDLE_KEEP_MS, now = Date.now }) {
93
113
  this.#root = root;
114
+ this.#idleTtlMs = Math.max(0, Number(idleTtlMs) || 0);
115
+ this.#now = now;
94
116
  }
95
117
 
96
118
  /** Where these files live, for logging. */
@@ -148,21 +170,40 @@ export class CompletedFiles {
148
170
  * @returns {{ path: string, length: number, name: string } | null}
149
171
  */
150
172
  find(infoHash, fileIndex) {
173
+ const file = this.peek(infoHash, fileIndex);
174
+ if (file) this.#touch(this.#keyOf(infoHash, fileIndex));
175
+ return file;
176
+ }
177
+
178
+ /**
179
+ * Look up a file for bookkeeping without extending its idle period.
180
+ *
181
+ * @param {string} infoHash
182
+ * @param {number} fileIndex
183
+ * @returns {{ path: string, length: number, name: string } | null}
184
+ */
185
+ peek(infoHash, fileIndex) {
151
186
  const key = this.#keyOf(infoHash, fileIndex);
152
187
  if (this.#removeAfterRead.has(key) || this.#removing.has(key)) return null;
153
- const file = this.#held.get(key) ?? null;
154
- if (file) {
155
- // WHEN IT WAS LAST WANTED, which is the order it leaves in. A whole file
156
- // nobody has asked for in the longest is the one whose loss costs least:
157
- // it can be downloaded again, and a read of it falls back to the pieces.
158
- this.#touch(key);
159
- }
160
- return file;
188
+ return this.#held.get(key) ?? null;
161
189
  }
162
190
 
163
191
  /** Mark a file as more recently wanted than every earlier access. */
164
192
  #touch(key) {
165
193
  this.#lastRead.set(key, ++this.#accessOrder);
194
+ this.#lastUsedAt.set(key, this.#now());
195
+ }
196
+
197
+ /** Remove reusable whole files that have had no access for their idle period. */
198
+ async expireIdle(now = this.#now()) {
199
+ return this.#withAdmission(async () => {
200
+ let removed = 0;
201
+ for (const [key, lastUsedAt] of this.#lastUsedAt) {
202
+ if (now - lastUsedAt < this.#idleTtlMs || this.#readers.has(key) || this.#writing.has(key)) continue;
203
+ if (await this.#remove(key)) removed += 1;
204
+ }
205
+ return { bytes: this.bytes, removed };
206
+ });
166
207
  }
167
208
 
168
209
  /** Pin a completed file against eviction until all its offset reads finish. */
@@ -180,6 +221,7 @@ export class CompletedFiles {
180
221
  else {
181
222
  this.#readers.delete(key);
182
223
  if (this.#removeAfterRead.delete(key)) await this.#remove(key);
224
+ else this.#touch(key);
183
225
  }
184
226
  return this.allow(this.#allowanceBytes);
185
227
  } };
@@ -289,7 +331,10 @@ export class CompletedFiles {
289
331
  }
290
332
  this.#held.delete(key);
291
333
  this.#lastRead.delete(key);
334
+ this.#lastUsedAt.delete(key);
292
335
  this.#removing.delete(key);
336
+ const infoHash = key.slice(0, key.lastIndexOf("/"));
337
+ await this.#writeManifest(infoHash);
293
338
  return true;
294
339
  }
295
340
 
@@ -331,6 +376,23 @@ export class CompletedFiles {
331
376
  // a file named by its number is better than a file thrown away.
332
377
  }
333
378
  for (const held of files) {
379
+ const ownedPartial = held.isFile() && /^\d+\.partial-(\d+)-[0-9a-f-]+$/i.exec(held.name);
380
+ const legacyPartial = held.isFile() && /^\d+\.partial$/.test(held.name);
381
+ if (ownedPartial && !writerIsAlive(Number(ownedPartial[1]))) {
382
+ await fs.rm(path.join(this.#root, infoHash, held.name), REMOVAL).catch(() => undefined);
383
+ continue;
384
+ }
385
+ if (legacyPartial) {
386
+ const partial = path.join(this.#root, infoHash, held.name);
387
+ try {
388
+ const { mtimeMs } = await fs.stat(partial);
389
+ if (this.#now() - mtimeMs >= this.#idleTtlMs) await fs.rm(partial, REMOVAL);
390
+ } catch {
391
+ // Gone between listing and inspection, or still open on Windows.
392
+ }
393
+ continue;
394
+ }
395
+ if (ownedPartial) continue;
334
396
  const fileIndex = Number.parseInt(held.name, 10);
335
397
  if (!held.isFile() || !Number.isInteger(fileIndex) || fileIndex < 0) {
336
398
  continue;
@@ -348,11 +410,13 @@ export class CompletedFiles {
348
410
  length: size,
349
411
  name: String(manifest?.[String(fileIndex)]?.name ?? fileIndex)
350
412
  });
413
+ this.#touch(this.#keyOf(infoHash, fileIndex));
351
414
  adopted += 1;
352
415
  } catch {
353
416
  // Gone between the listing and the reading: not ours to worry about.
354
417
  }
355
418
  }
419
+ await this.#pruneTorrentDirectory(infoHash);
356
420
  }
357
421
  return adopted;
358
422
  }
@@ -407,12 +471,11 @@ export class CompletedFiles {
407
471
  }
408
472
  const directory = path.join(this.#root, String(infoHash));
409
473
  const where = path.join(directory, String(fileIndex));
410
- const partial = `${where}.partial`;
474
+ const partial = `${where}.partial-${process.pid}-${randomUUID()}`;
411
475
  try {
412
476
  await fs.mkdir(directory, { recursive: true });
413
- await fs.rm(partial, REMOVAL);
414
477
  const source = open();
415
- const handle = await fs.open(partial, "w");
478
+ const handle = await fs.open(partial, "wx");
416
479
  let written = 0;
417
480
  try {
418
481
  for await (const chunk of source) {
@@ -440,8 +503,8 @@ export class CompletedFiles {
440
503
  this.#touch(key);
441
504
  this.#writing.delete(key);
442
505
  await this.#trimToAllowance();
506
+ await this.#writeManifest(infoHash);
443
507
  });
444
- await this.#writeManifest(infoHash);
445
508
  return this.#held.get(key) ?? null;
446
509
  } catch {
447
510
  await fs.rm(partial, REMOVAL).catch(() => undefined);
@@ -451,6 +514,7 @@ export class CompletedFiles {
451
514
  this.#reservations.delete(key);
452
515
  this.#writing.delete(key);
453
516
  await this.#trimToAllowance();
517
+ await this.#pruneTorrentDirectory(infoHash);
454
518
  });
455
519
  }
456
520
  }
@@ -472,9 +536,24 @@ export class CompletedFiles {
472
536
  named[key.slice(infoHash.length + 1)] = { length: file.length, name: file.name };
473
537
  }
474
538
  }
475
- await fs
476
- .writeFile(path.join(this.#root, String(infoHash), "manifest.json"), JSON.stringify(named))
477
- .catch(() => undefined);
539
+ const directory = path.join(this.#root, String(infoHash));
540
+ if (Object.keys(named).length === 0 && ![...this.#writing].some(key => key.startsWith(`${infoHash}/`))) {
541
+ await fs.rm(directory, { ...REMOVAL, recursive: true }).catch(() => undefined);
542
+ return;
543
+ }
544
+ await fs.writeFile(path.join(directory, "manifest.json"), JSON.stringify(named)).catch(() => undefined);
545
+ }
546
+
547
+ /** Remove an empty or partial-only directory discovered during startup. */
548
+ async #pruneTorrentDirectory(infoHash) {
549
+ if ([...this.#held.keys()].some(key => key.startsWith(`${infoHash}/`)) ||
550
+ [...this.#writing].some(key => key.startsWith(`${infoHash}/`))) return;
551
+ const directory = path.join(this.#root, String(infoHash));
552
+ let names = [];
553
+ try { names = await fs.readdir(directory); } catch { return; }
554
+ if (names.every(name => name === "manifest.json")) {
555
+ await fs.rm(directory, { ...REMOVAL, recursive: true }).catch(() => undefined);
556
+ }
478
557
  }
479
558
 
480
559
  /**
@@ -489,7 +568,8 @@ export class CompletedFiles {
489
568
  await this.#remove(key);
490
569
  }
491
570
  }
492
- if (![...this.#held.keys()].some(key => key.startsWith(`${infoHash}/`))) {
571
+ if (![...this.#held.keys()].some(key => key.startsWith(`${infoHash}/`)) &&
572
+ ![...this.#writing].some(key => key.startsWith(`${infoHash}/`))) {
493
573
  await fs.rm(path.join(this.#root, String(infoHash)), { ...REMOVAL, recursive: true }).catch(() => undefined);
494
574
  }
495
575
  }
@@ -42,7 +42,7 @@
42
42
  /**
43
43
  * How long material nobody has read is kept, in milliseconds.
44
44
  *
45
- * Read by the torrent pool for a torrent and its downloaded bytes, and by the
46
- * session manager for the segments an encoder produced.
45
+ * Read by the torrent pool, the completed-file store, and the session manager
46
+ * for reusable torrents, source files, and generated segments.
47
47
  */
48
48
  export const IDLE_KEEP_MS = 60 * 60 * 1000;
@@ -56,9 +56,9 @@ import { availableMemoryBytes } from "./machine-memory.js";
56
56
  * @param {import("./MachineBudget.js").BudgetPolicy} [params.policy] - What the
57
57
  * operator has said this proxy may take. Absent, the measured default.
58
58
  * @param {{ info: Function, warn?: Function }} [params.logger]
59
- * @returns {{ revise: () => Promise<unknown>, segmentBytes: () => number, diagnostics: object | null, describe: () => string }}
60
- * What the segments may hold is asked for rather than pushed: zero until the
61
- * first revision, and zero stops growth rather than licensing it.
59
+ * @returns {{ revise: () => Promise<unknown>, segmentBytes: () => number | null, diagnostics: object | null, describe: () => string }}
60
+ * What the segments may hold is asked for rather than pushed: null until the
61
+ * first revision, then the measured allowance, including a measured zero.
62
62
  */
63
63
  export function wireMachineBudget({
64
64
  segmentStore,
@@ -101,7 +101,7 @@ export function wireMachineBudget({
101
101
  const segmentsOn = deviceOf(segmentStore.root);
102
102
  defineDisk(segmentsOn, segmentStore.root);
103
103
 
104
- let segmentBytes = 0;
104
+ let segmentBytes = null;
105
105
  budget.register({
106
106
  name: "segments",
107
107
  resource: segmentsOn,
@@ -1007,7 +1007,7 @@ const wholeSources = createWholeSources({ find: (infoHash, fileIndex) => complet
1007
1007
  /** Adopted files and newly completed files state the same availability event. */
1008
1008
  function announceWholeFiles(sourceKey, description) {
1009
1009
  for (const file of description?.files ?? []) {
1010
- const held = completedFiles.find(description.infoHash, file.index);
1010
+ const held = completedFiles.peek(description.infoHash, file.index);
1011
1011
  if (!held || held.length !== file.length) continue;
1012
1012
  parentPort.postMessage({ type: Event.FILE_COMPLETE, sourceKey, infoHash: description.infoHash,
1013
1013
  fileIndex: file.index, path: held.path, length: held.length, name: file.name });
@@ -1085,6 +1085,9 @@ const beingKept = new Set();
1085
1085
  * @returns {Promise<void>}
1086
1086
  */
1087
1087
  async function keepWholeFiles() {
1088
+ const expired = await completedFiles.expireIdle();
1089
+ if (expired.removed > 0) holdingsChanged();
1090
+
1088
1091
  for (const [sourceKey, torrent] of [...pool.torrents]) {
1089
1092
  // Lower case, because that is what the source key carries and the main
1090
1093
  // thread looks these up by the key alone.
@@ -1104,7 +1107,7 @@ async function keepWholeFiles() {
1104
1107
  const toKeep = filesToKeep({
1105
1108
  files: torrent.files,
1106
1109
  isKept: (fileIndex) =>
1107
- completedFiles.find(infoHash, fileIndex) !== null || beingKept.has(`${infoHash}/${fileIndex}`),
1110
+ completedFiles.peek(infoHash, fileIndex) !== null || beingKept.has(`${infoHash}/${fileIndex}`),
1108
1111
  inUse: wanted
1109
1112
  });
1110
1113
  let announcedNow = false;
@@ -1169,7 +1172,7 @@ async function keepWholeFiles() {
1169
1172
  // above can take minutes and a read can open while it runs.
1170
1173
  const isRemovable = mayRemoveWholeTorrent({
1171
1174
  torrent,
1172
- isKept: (fileIndex) => completedFiles.find(infoHash, fileIndex) !== null,
1175
+ isKept: (fileIndex) => completedFiles.peek(infoHash, fileIndex) !== null,
1173
1176
  openReads: openReads.values(),
1174
1177
  announcedNow
1175
1178
  });