@torrent-tv/proxy 2.101.23 → 2.101.25
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 +1 -1
- package/routes/api/client-logs/post.js +15 -2
- package/routes/api/sources/files/get.js +12 -3
- package/routes/api/sources/stats/get.js +27 -9
- package/server.js +4 -3
- package/services/encode/LocalObservations.js +102 -5
- package/services/server/ViewerRequests.js +127 -0
- package/services/server/wire-outputs.js +2 -0
- package/services/storage/files/CompletedFiles.js +97 -17
- package/services/storage/keep.js +2 -2
- package/services/torrent/worker/worker.js +6 -3
package/package.json
CHANGED
|
@@ -63,10 +63,10 @@ function sanitizeLine(value) {
|
|
|
63
63
|
*
|
|
64
64
|
* @param {import("fastify").FastifyRequest} req
|
|
65
65
|
* @param {import("fastify").FastifyReply} reply
|
|
66
|
-
* @param {{ clientLogs: { write: (session: object, lines: string[]) => void } | null }} deps
|
|
66
|
+
* @param {{ clientLogs: { write: (session: object, lines: string[]) => void } | null, viewerRequests?: object }} deps
|
|
67
67
|
* @returns {Promise<unknown>}
|
|
68
68
|
*/
|
|
69
|
-
export async function handleApiClientLogsPost(req, reply, { clientLogs }) {
|
|
69
|
+
export async function handleApiClientLogsPost(req, reply, { clientLogs, viewerRequests }) {
|
|
70
70
|
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
71
71
|
const session = {
|
|
72
72
|
sessionId: safeString(body.sessionId, MAX_SID_LEN) || "unknown",
|
|
@@ -89,5 +89,18 @@ export async function handleApiClientLogsPost(req, reply, { clientLogs }) {
|
|
|
89
89
|
if (lines.length > 0) {
|
|
90
90
|
clientLogs?.write(session, lines);
|
|
91
91
|
}
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
const message = row && typeof row.msg === "string" ? row.msg : "";
|
|
94
|
+
const match = /^\[first-frame-observation\] consumer=([^ ]+) generation=(\d+) waitId=([^ ]+) wait=(\d+) audioReady=(true|false) elapsedMs=([\d.]+)$/.exec(message);
|
|
95
|
+
if (match) {
|
|
96
|
+
const elapsedMs = Number(match[6]);
|
|
97
|
+
if (match[1].length <= 64 && Number.isSafeInteger(Number(match[2])) && match[3].length <= 256 &&
|
|
98
|
+
Number.isSafeInteger(Number(match[4])) && match[5] === "true" &&
|
|
99
|
+
Number.isFinite(elapsedMs) && elapsedMs >= 0) {
|
|
100
|
+
viewerRequests?.noteFirstFrameObservation?.({ consumerId: match[1], generation: Number(match[2]),
|
|
101
|
+
waitId: match[3], wait: Number(match[4]), elapsedMs });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
92
105
|
return reply.code(204).send();
|
|
93
106
|
}
|
|
@@ -15,14 +15,15 @@ import { logger } from "../../../../utils/logger.js";
|
|
|
15
15
|
* @param {import("fastify").FastifyReply} reply
|
|
16
16
|
* @param {{
|
|
17
17
|
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
18
|
-
* torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool
|
|
18
|
+
* torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool,
|
|
19
|
+
* viewerRequests?: object
|
|
19
20
|
* }} deps
|
|
20
21
|
* @returns {Promise<void>}
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
const CANCELLED = Symbol("cancelled");
|
|
24
25
|
|
|
25
|
-
export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers }) {
|
|
26
|
+
export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers, viewerRequests }) {
|
|
26
27
|
const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
27
28
|
if (!sourceKey) {
|
|
28
29
|
return reply.code(400).send({ error: "sourceKey is required." });
|
|
@@ -72,13 +73,21 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
|
|
|
72
73
|
}
|
|
73
74
|
const consumerId = typeof req.query?.consumerId === "string" ? req.query.consumerId.trim() : "";
|
|
74
75
|
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
|
+
const files = contents.files().map(file => {
|
|
79
|
+
if (!consumerId || !Number.isSafeInteger(generation) || file.kind !== "video" || file.excludedReason) return file;
|
|
80
|
+
const waitEstimate = viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey,
|
|
81
|
+
fileIndex: file.fileIndex, fileName: file.relativePath ?? file.name, fileLength: file.length }) ?? null;
|
|
82
|
+
return waitEstimate ? { ...file, waitEstimate } : file;
|
|
83
|
+
});
|
|
75
84
|
return reply.send({
|
|
76
85
|
name: torrent.name ?? "",
|
|
77
86
|
infoHash: torrent.infoHash ?? "",
|
|
78
87
|
// In the order a person reads them — by folder, then by name, with runs of
|
|
79
88
|
// digits compared as numbers. A torrent's own order is whatever the tool
|
|
80
89
|
// that made it chose, and it is routinely by size.
|
|
81
|
-
files
|
|
90
|
+
files,
|
|
82
91
|
// The pictures, each with what belongs to it. By index, because the files
|
|
83
92
|
// themselves are in the list above and saying them twice is how two copies
|
|
84
93
|
// of one fact start.
|
|
@@ -13,11 +13,12 @@ import { logger } from "../../../../utils/logger.js";
|
|
|
13
13
|
* @param {import("fastify").FastifyReply} reply
|
|
14
14
|
* @param {{
|
|
15
15
|
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
16
|
-
* torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool
|
|
16
|
+
* torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool,
|
|
17
|
+
* viewerRequests?: object
|
|
17
18
|
* }} deps
|
|
18
19
|
* @returns {Promise<void>}
|
|
19
20
|
*/
|
|
20
|
-
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
21
|
+
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests }) {
|
|
21
22
|
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
22
23
|
if (!sourceKey) {
|
|
23
24
|
return reply.code(400).send({ error: "sourceKey is required." });
|
|
@@ -28,6 +29,19 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
28
29
|
return reply.code(404).send({ error: "Source key was not found." });
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
33
|
+
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
34
|
+
const consumerId = typeof req.query.consumer === "string" ? req.query.consumer : "";
|
|
35
|
+
const generationRaw = typeof req.query.generation === "string" ? req.query.generation : "";
|
|
36
|
+
const generation = generationRaw !== "" && /^\d+$/.test(generationRaw) ? Number(generationRaw) : null;
|
|
37
|
+
const requestedFileName = typeof req.query.fileName === "string" ? req.query.fileName : "";
|
|
38
|
+
const requestedFileLength = typeof req.query.fileLength === "string" && /^\d+$/.test(req.query.fileLength)
|
|
39
|
+
? Number(req.query.fileLength) : null;
|
|
40
|
+
const initialEstimate = () => consumerId && Number.isSafeInteger(generation) && Number.isSafeInteger(fileIndex)
|
|
41
|
+
? viewerRequests?.waitEstimateForFile?.({ consumerId, generation, sourceKey, fileIndex,
|
|
42
|
+
fileName: requestedFileName, fileLength: requestedFileLength }) ?? null
|
|
43
|
+
: null;
|
|
44
|
+
|
|
31
45
|
let torrent;
|
|
32
46
|
try {
|
|
33
47
|
torrent = torrentPool.knownTorrent(sourceKey);
|
|
@@ -37,18 +51,18 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
37
51
|
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
38
52
|
}
|
|
39
53
|
if (!torrent) {
|
|
40
|
-
|
|
54
|
+
const waitEstimate = initialEstimate();
|
|
55
|
+
return reply.code(202).send({ pending: true, ...(waitEstimate ? { waitEstimate } : {}) });
|
|
41
56
|
}
|
|
42
57
|
|
|
43
|
-
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
44
|
-
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
45
|
-
|
|
46
|
-
|
|
47
58
|
// Awaited: with the torrent on its own thread this is a round trip, not a
|
|
48
59
|
// local lookup. Without the await the reply was the pending promise itself,
|
|
49
60
|
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
50
61
|
const stats = await torrentPool.getFileStats(torrent, fileIndex);
|
|
51
|
-
if (stats?.pending || !stats)
|
|
62
|
+
if (stats?.pending || !stats) {
|
|
63
|
+
const waitEstimate = initialEstimate();
|
|
64
|
+
return reply.code(202).send({ pending: true, ...(waitEstimate ? { waitEstimate } : {}) });
|
|
65
|
+
}
|
|
52
66
|
|
|
53
67
|
// Diagnostic: surface the real swarm state per poll so a cold-start download
|
|
54
68
|
// stall (0 peers / header not advancing → playback-plan blocks on the codec
|
|
@@ -121,5 +135,9 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
121
135
|
`file=${filePct} header=${header}${detail}`
|
|
122
136
|
);
|
|
123
137
|
|
|
124
|
-
|
|
138
|
+
const waitEstimate = consumerId && Number.isSafeInteger(generation)
|
|
139
|
+
? viewerRequests?.waitEstimateForSource?.({ consumerId, generation, sourceKey, fileIndex,
|
|
140
|
+
fileName: torrent.files?.[fileIndex]?.name ?? requestedFileName, fileLength: requestedFileLength, stats }) ?? null
|
|
141
|
+
: null;
|
|
142
|
+
return reply.send(waitEstimate ? { ...stats, waitEstimate } : stats);
|
|
125
143
|
}
|
package/server.js
CHANGED
|
@@ -1406,10 +1406,11 @@ export async function startProxyServer({
|
|
|
1406
1406
|
return handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers });
|
|
1407
1407
|
});
|
|
1408
1408
|
app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
|
|
1409
|
-
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
|
|
1409
|
+
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests: outputParts.viewerRequests })
|
|
1410
1410
|
);
|
|
1411
1411
|
app.get("/api/sources/:sourceKey/files", async (req, reply) =>
|
|
1412
|
-
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers: outputParts.viewers
|
|
1412
|
+
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers: outputParts.viewers,
|
|
1413
|
+
viewerRequests: outputParts.viewerRequests })
|
|
1413
1414
|
);
|
|
1414
1415
|
app.get("/api/sources/:sourceKey/files/:fileIndex/fingerprint", async (req, reply) =>
|
|
1415
1416
|
handleApiSourceFingerprintGet(req, reply, {
|
|
@@ -1463,7 +1464,7 @@ export async function startProxyServer({
|
|
|
1463
1464
|
? createClientLogFiles(logFile)
|
|
1464
1465
|
: createClientLogConsole((message) => logger.info(message));
|
|
1465
1466
|
app.post("/api/client-logs", async (req, reply) =>
|
|
1466
|
-
handleApiClientLogsPost(req, reply, { clientLogs })
|
|
1467
|
+
handleApiClientLogsPost(req, reply, { clientLogs, viewerRequests: outputParts.viewerRequests })
|
|
1467
1468
|
);
|
|
1468
1469
|
app.post("/api/playback-plan", async (req, reply) =>
|
|
1469
1470
|
handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers: outputParts.viewers })
|
|
@@ -85,8 +85,11 @@ export class LocalObservations {
|
|
|
85
85
|
/** @type {() => number} */
|
|
86
86
|
#now;
|
|
87
87
|
|
|
88
|
-
/** @type {
|
|
89
|
-
#
|
|
88
|
+
/** @type {Set<string>} */
|
|
89
|
+
#configurations = new Set();
|
|
90
|
+
|
|
91
|
+
/** @type {{ speed: Record<string, object[]>, rates: Record<string, object[]>, preparation: Record<string, object[]>, firstFrame: Record<string, object[]> }} */
|
|
92
|
+
#entries = { speed: {}, rates: {}, preparation: {}, firstFrame: {} };
|
|
90
93
|
|
|
91
94
|
/**
|
|
92
95
|
* @param {object} params
|
|
@@ -152,6 +155,7 @@ export class LocalObservations {
|
|
|
152
155
|
* @returns {void}
|
|
153
156
|
*/
|
|
154
157
|
load(currentConfigurations) {
|
|
158
|
+
this.#configurations = new Set(currentConfigurations);
|
|
155
159
|
if (!this.#filePath) {
|
|
156
160
|
return;
|
|
157
161
|
}
|
|
@@ -168,11 +172,14 @@ export class LocalObservations {
|
|
|
168
172
|
const allowed = new Set(currentConfigurations);
|
|
169
173
|
let kept = 0;
|
|
170
174
|
let dropped = 0;
|
|
171
|
-
|
|
175
|
+
const firstFrameKey = this.#firstFrameKey();
|
|
176
|
+
for (const kind of ["speed", "rates", "preparation", "firstFrame"]) {
|
|
172
177
|
const section = raw.entries[kind] && typeof raw.entries[kind] === "object" ? raw.entries[kind] : {};
|
|
173
178
|
for (const [key, readings] of Object.entries(section)) {
|
|
174
|
-
const
|
|
175
|
-
|
|
179
|
+
const matches = kind === "firstFrame"
|
|
180
|
+
? key === firstFrameKey
|
|
181
|
+
: allowed.has(key.split("|")[0]);
|
|
182
|
+
if (!matches || !Array.isArray(readings)) {
|
|
176
183
|
dropped += 1;
|
|
177
184
|
continue;
|
|
178
185
|
}
|
|
@@ -256,6 +263,75 @@ export class LocalObservations {
|
|
|
256
263
|
this.#save();
|
|
257
264
|
}
|
|
258
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Keep a browser-measured wait outcome with the source and link readings
|
|
268
|
+
* visible before the container was inspected. These readings are useful
|
|
269
|
+
* only on the encoder configurations present on this host.
|
|
270
|
+
*
|
|
271
|
+
* @param {{ seconds: number, features: object }} params
|
|
272
|
+
* @returns {void}
|
|
273
|
+
*/
|
|
274
|
+
noteFirstFrame({ seconds, features }) {
|
|
275
|
+
if (!Number.isFinite(seconds) || seconds < 0 || !features || typeof features !== "object") return;
|
|
276
|
+
const key = this.#firstFrameKey();
|
|
277
|
+
const readings = [...(this.#entries.firstFrame[key] ?? []), {
|
|
278
|
+
seconds,
|
|
279
|
+
extension: typeof features.extension === "string" ? features.extension : "",
|
|
280
|
+
fileLength: positive(features.fileLength),
|
|
281
|
+
downloadSpeed: nonNegative(features.downloadSpeed),
|
|
282
|
+
linkMbps: nonNegative(features.linkMbps),
|
|
283
|
+
headerRemaining: nonNegative(features.headerRemaining),
|
|
284
|
+
peers: nonNegative(features.peers),
|
|
285
|
+
at: this.#now()
|
|
286
|
+
}].slice(-READINGS_KEPT);
|
|
287
|
+
this.#entries.firstFrame[key] = readings;
|
|
288
|
+
this.#save();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Predict this host's first-frame wait from the closest measured start with
|
|
293
|
+
* the same media suffix. Distance is the sum of squared log-ratios for the
|
|
294
|
+
* independent measured inputs; it does not mix bytes, bit rates and peers
|
|
295
|
+
* in their raw units.
|
|
296
|
+
*
|
|
297
|
+
* @param {object} features
|
|
298
|
+
* @returns {number | null}
|
|
299
|
+
*/
|
|
300
|
+
firstFrameSeconds(features) {
|
|
301
|
+
if (!features || typeof features !== "object") return null;
|
|
302
|
+
const readings = this.#entries.firstFrame[this.#firstFrameKey()] ?? [];
|
|
303
|
+
const candidates = readings.filter(reading => reading.extension === (features.extension ?? ""));
|
|
304
|
+
if (candidates.length === 0) return null;
|
|
305
|
+
const dimensions = ["fileLength", "downloadSpeed", "linkMbps", "headerRemaining", "peers"];
|
|
306
|
+
const scales = new Map(dimensions.map(key => [key, median(candidates
|
|
307
|
+
.map(reading => nonNegative(reading[key]))
|
|
308
|
+
.filter(value => value !== null && value > 0))]));
|
|
309
|
+
let best = null;
|
|
310
|
+
for (const reading of candidates) {
|
|
311
|
+
let distance = 0;
|
|
312
|
+
let used = 0;
|
|
313
|
+
for (const key of dimensions) {
|
|
314
|
+
const scale = scales.get(key);
|
|
315
|
+
const current = nonNegative(features[key]);
|
|
316
|
+
const previous = nonNegative(reading[key]);
|
|
317
|
+
if (scale === null || current === null || previous === null) continue;
|
|
318
|
+
const difference = Math.log1p(current / scale) - Math.log1p(previous / scale);
|
|
319
|
+
distance += difference * difference;
|
|
320
|
+
used += 1;
|
|
321
|
+
}
|
|
322
|
+
if (used === 0) continue;
|
|
323
|
+
distance /= used;
|
|
324
|
+
if (!best || distance < best.distance || (distance === best.distance && reading.at > best.reading.at)) {
|
|
325
|
+
best = { distance, reading };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return best?.reading.seconds ?? null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#firstFrameKey() {
|
|
332
|
+
return `firstFrame:${[...this.#configurations].sort().join("||")}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
259
335
|
/**
|
|
260
336
|
* The slowest this mode has been seen running alone on comparable material,
|
|
261
337
|
* or null where nothing comparable has been seen.
|
|
@@ -349,6 +425,27 @@ export class LocalObservations {
|
|
|
349
425
|
}
|
|
350
426
|
}
|
|
351
427
|
|
|
428
|
+
/** @param {unknown} value @returns {number | null} */
|
|
429
|
+
function positive(value) {
|
|
430
|
+
const number = Number(value);
|
|
431
|
+
return Number.isFinite(number) && number > 0 ? number : null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** @param {unknown} value @returns {number | null} */
|
|
435
|
+
function nonNegative(value) {
|
|
436
|
+
if (value === null || value === undefined || value === "") return null;
|
|
437
|
+
const number = Number(value);
|
|
438
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** @param {number[]} values @returns {number | null} */
|
|
442
|
+
function median(values) {
|
|
443
|
+
if (values.length === 0) return null;
|
|
444
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
445
|
+
const middle = Math.floor(sorted.length / 2);
|
|
446
|
+
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
|
|
447
|
+
}
|
|
448
|
+
|
|
352
449
|
/**
|
|
353
450
|
* What an observation of an encode is filed as having been made FROM.
|
|
354
451
|
*
|
|
@@ -60,6 +60,8 @@ export class ViewerRequests {
|
|
|
60
60
|
#host;
|
|
61
61
|
/** @type {WeakMap<object, Map<string, object>>} */
|
|
62
62
|
#rateStateBySession = new WeakMap();
|
|
63
|
+
/** @type {Map<string, { consumerId: string, generation: number, fileIndex: number | null, waitId: string, features: object, updatedAt: number, revision: number, observedWaits: Set<number> }>} */
|
|
64
|
+
#earlyWaits = new Map();
|
|
63
65
|
/**
|
|
64
66
|
* What the start forecasts cost, said once a minute: how many there were,
|
|
65
67
|
* their time on the main thread, and for each track whether the times of its
|
|
@@ -97,6 +99,131 @@ export class ViewerRequests {
|
|
|
97
99
|
this.#host = host;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Attach a host-matched estimate to the file list before the viewer chooses
|
|
104
|
+
* a file, so the original action need not wait for a torrent-worker round trip.
|
|
105
|
+
*
|
|
106
|
+
* @param {{ consumerId: string, generation: number, sourceKey: string, fileIndex: number, fileName: string, fileLength: number }} params
|
|
107
|
+
* @returns {object | null}
|
|
108
|
+
*/
|
|
109
|
+
waitEstimateForFile({ consumerId, generation, sourceKey, fileIndex, fileName, fileLength }) {
|
|
110
|
+
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string" ||
|
|
111
|
+
!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null;
|
|
112
|
+
const waitId = `${sourceKey}:${fileIndex}:${generation}`;
|
|
113
|
+
const key = `${consumerId}:${waitId}`;
|
|
114
|
+
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
115
|
+
const features = {
|
|
116
|
+
extension,
|
|
117
|
+
fileLength,
|
|
118
|
+
downloadSpeed: null,
|
|
119
|
+
linkMbps: this.#host.viewers.get(consumerId)?.linkReading()?.linkMbps,
|
|
120
|
+
headerRemaining: null,
|
|
121
|
+
peers: null
|
|
122
|
+
};
|
|
123
|
+
const current = this.#earlyWaits.get(key);
|
|
124
|
+
if (current?.waitId === waitId && current.hasSourceObservation) {
|
|
125
|
+
const seconds = this.#host.firstFrameSecondsOf?.(current.features) ?? null;
|
|
126
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
127
|
+
return { version: 1, waitId, revision: current.revision, remainingSeconds: seconds, ageMs: 0 };
|
|
128
|
+
}
|
|
129
|
+
const sameWait = current?.waitId === waitId;
|
|
130
|
+
const revision = sameWait ? current.revision : 0;
|
|
131
|
+
const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current });
|
|
132
|
+
const changed = this.#recordEarlyWaitSnapshot(context, features);
|
|
133
|
+
if (sameWait && changed) context.revision += 1;
|
|
134
|
+
this.#pruneEarlyWaits();
|
|
135
|
+
const seconds = this.#host.firstFrameSecondsOf?.(features) ?? null;
|
|
136
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
137
|
+
return { version: 1, waitId, revision, remainingSeconds: seconds, ageMs: 0 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Keep the measurements available before the container is inspected and
|
|
142
|
+
* predict the complete first-frame wait from this host's earlier outcomes.
|
|
143
|
+
*
|
|
144
|
+
* @param {{ consumerId: string, generation: number, sourceKey: string, fileIndex: number | null, fileName?: string, fileLength?: number | null, stats?: object }} params
|
|
145
|
+
* @returns {object | null}
|
|
146
|
+
*/
|
|
147
|
+
waitEstimateForSource({ consumerId, generation, sourceKey, fileIndex, fileName = "", fileLength = null, stats = {} }) {
|
|
148
|
+
if (!consumerId || !Number.isSafeInteger(generation) || typeof sourceKey !== "string") return null;
|
|
149
|
+
const waitId = `${sourceKey}:${fileIndex ?? "all"}:${generation}`;
|
|
150
|
+
const key = `${consumerId}:${waitId}`;
|
|
151
|
+
const extension = typeof fileName === "string" ? fileName.split(".").at(-1)?.toLowerCase() ?? "" : "";
|
|
152
|
+
const features = {
|
|
153
|
+
extension,
|
|
154
|
+
fileLength: Number.isFinite(stats?.fileLength) && stats.fileLength > 0
|
|
155
|
+
? stats.fileLength : fileLength,
|
|
156
|
+
downloadSpeed: stats?.downloadSpeed,
|
|
157
|
+
linkMbps: this.#host.viewers.get(consumerId)?.linkReading()?.linkMbps,
|
|
158
|
+
headerRemaining: Number.isFinite(stats?.headerBytes) && Number.isFinite(stats?.headerDownloadedBytes)
|
|
159
|
+
? Math.max(0, stats.headerBytes - stats.headerDownloadedBytes) : null,
|
|
160
|
+
peers: stats?.connectedPeers ?? stats?.numPeers
|
|
161
|
+
};
|
|
162
|
+
const current = this.#earlyWaits.get(key);
|
|
163
|
+
const revision = current?.waitId === waitId ? current.revision + 1 : 0;
|
|
164
|
+
const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
|
|
165
|
+
sourceObserved: true });
|
|
166
|
+
this.#recordEarlyWaitSnapshot(context, features);
|
|
167
|
+
this.#pruneEarlyWaits();
|
|
168
|
+
const seconds = this.#host.firstFrameSecondsOf?.(features) ?? null;
|
|
169
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
170
|
+
return { version: 1, waitId, revision, remainingSeconds: seconds, ageMs: 0 };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Learn from the browser's presented-frame measurement for the current
|
|
175
|
+
* viewing. The saved source and link readings are the ones available at
|
|
176
|
+
* selection time, before the full track plan existed.
|
|
177
|
+
*
|
|
178
|
+
* @param {{ consumerId: string, generation: number, waitId: string, wait: number, elapsedMs: number }} params
|
|
179
|
+
* @returns {void}
|
|
180
|
+
*/
|
|
181
|
+
noteFirstFrameObservation({ consumerId, generation, waitId, wait, elapsedMs }) {
|
|
182
|
+
const key = `${consumerId}:${waitId}`;
|
|
183
|
+
const early = this.#earlyWaits.get(key);
|
|
184
|
+
if (!early || early.generation !== generation || !Number.isSafeInteger(wait) || wait < 0 || early.observedWaits.has(wait) ||
|
|
185
|
+
!Number.isFinite(elapsedMs) || elapsedMs < 0) return;
|
|
186
|
+
for (const snapshot of early.snapshots) {
|
|
187
|
+
this.#host.noteFirstFrame?.({ seconds: elapsedMs / 1000, features: snapshot });
|
|
188
|
+
}
|
|
189
|
+
early.observedWaits.add(wait);
|
|
190
|
+
early.updatedAt = Date.now();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current, sourceObserved = false }) {
|
|
194
|
+
const sameWait = current?.waitId === waitId;
|
|
195
|
+
const context = sameWait ? current : {
|
|
196
|
+
consumerId,
|
|
197
|
+
generation,
|
|
198
|
+
fileIndex,
|
|
199
|
+
waitId,
|
|
200
|
+
snapshots: [],
|
|
201
|
+
hasSourceObservation: false,
|
|
202
|
+
observedWaits: new Set()
|
|
203
|
+
};
|
|
204
|
+
Object.assign(context, { consumerId, generation, fileIndex, waitId, features, updatedAt: Date.now(), revision,
|
|
205
|
+
hasSourceObservation: context.hasSourceObservation || sourceObserved });
|
|
206
|
+
this.#earlyWaits.set(`${consumerId}:${waitId}`, context);
|
|
207
|
+
return context;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#recordEarlyWaitSnapshot(context, features) {
|
|
211
|
+
const previous = context.snapshots.at(-1);
|
|
212
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(features)) return false;
|
|
213
|
+
const snapshot = { ...features };
|
|
214
|
+
if (context.snapshots.length < 2) context.snapshots.push(snapshot);
|
|
215
|
+
else context.snapshots[1] = snapshot;
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#pruneEarlyWaits() {
|
|
220
|
+
const cutoff = Date.now() - 30 * 60_000;
|
|
221
|
+
for (const [key, value] of this.#earlyWaits) {
|
|
222
|
+
if (value.updatedAt < cutoff) this.#earlyWaits.delete(key);
|
|
223
|
+
}
|
|
224
|
+
while (this.#earlyWaits.size > 1000) this.#earlyWaits.delete(this.#earlyWaits.keys().next().value);
|
|
225
|
+
}
|
|
226
|
+
|
|
100
227
|
/**
|
|
101
228
|
* Open an output of a file for this viewer and put them on it.
|
|
102
229
|
*
|
|
@@ -650,6 +650,8 @@ export function wireOutputs({
|
|
|
650
650
|
const predicted = Number(qualityStateOf(output).predictedSpeedWhenOffered);
|
|
651
651
|
return Number.isFinite(predicted) && predicted > 0 ? predicted : null;
|
|
652
652
|
},
|
|
653
|
+
firstFrameSecondsOf: (features) => parts.localObservations?.firstFrameSeconds(features) ?? null,
|
|
654
|
+
noteFirstFrame: (observation) => parts.localObservations?.noteFirstFrame(observation),
|
|
653
655
|
audioDescriptionOf,
|
|
654
656
|
openSecondsOf: (output) => runStartSecondsOf(output),
|
|
655
657
|
processingStartupSecondsOf: (output) => runStartSecondsOf(output),
|
|
@@ -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
|
-
|
|
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, "
|
|
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
|
-
|
|
476
|
-
|
|
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
|
}
|
package/services/storage/keep.js
CHANGED
|
@@ -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
|
|
46
|
-
*
|
|
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;
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
1175
|
+
isKept: (fileIndex) => completedFiles.peek(infoHash, fileIndex) !== null,
|
|
1173
1176
|
openReads: openReads.values(),
|
|
1174
1177
|
announcedNow
|
|
1175
1178
|
});
|