@torrent-tv/proxy 2.83.3 → 2.83.5
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/CHANGELOG.md +32 -0
- package/package.json +1 -1
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -0
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -0
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -0
- package/routes/api/sources/files/get.js +23 -9
- package/routes/api/sources/warm/post.js +26 -22
- package/routes/api/transcode-sessions/post.js +1 -49
- package/routes/stream/get.js +2 -36
- package/services/controllers/SubtitleController.js +0 -3
- package/services/download/withdraw-claim.js +80 -0
- package/services/hls-session-manager.js +17 -110
- package/services/orchestrators/EncodeOrchestrator.js +133 -0
- package/services/piece-store/piece-disk-store.js +24 -1
- package/services/piece-store/shared-piece-store.js +71 -1
- package/services/playback-planner.js +12 -13
- package/services/priority/PriorityOrchestrator.js +40 -6
- package/services/torrent/Contents.js +324 -0
- package/services/torrent/files.js +8 -1
- package/services/torrent-pool.js +378 -102
- package/services/torrent-worker/client.js +0 -24
- package/services/torrent-worker/piece-reader.js +30 -1
- package/services/torrent-worker/pool-adapter.js +3 -33
- package/services/torrent-worker/protocol.js +0 -4
- package/services/torrent-worker/worker.js +60 -60
- package/test/file-edges.test.js +191 -0
- package/test/input-lost-quiets-the-plan.test.js +261 -0
- package/test/logger-repeats.test.js +120 -0
- package/test/priority-map-emptied.test.js +207 -0
- package/test/read-survives-withdrawal.test.js +164 -0
- package/test/source-files-route.test.js +103 -0
- package/test/stream-route.test.js +4 -8
- package/test/swarm-follows-readers.test.js +96 -14
- package/test/torrent-contents.test.js +235 -0
- package/test/upload-hurry.test.js +66 -28
- package/test/withdraw-piece-claim.test.js +212 -0
- package/utils/logger.js +105 -7
- package/services/torrent-worker/file-claims.js +0 -91
- package/test/file-claims.test.js +0 -64
|
@@ -317,30 +317,6 @@ export class TorrentWorkerClient {
|
|
|
317
317
|
return this.#caller.call(Command.LIST_FILES, { sourceKey });
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
/**
|
|
321
|
-
* Claim a file so it is not evicted while being read.
|
|
322
|
-
*
|
|
323
|
-
* @param {string} sourceKey
|
|
324
|
-
* @param {number} fileIndex
|
|
325
|
-
* @returns {Promise<string>} The claim's identity, for {@link releaseFile}.
|
|
326
|
-
*/
|
|
327
|
-
async acquireFile(sourceKey, fileIndex) {
|
|
328
|
-
return this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Drop one claim taken with {@link acquireFile}.
|
|
333
|
-
*
|
|
334
|
-
* Named by claim rather than by file: several readers hold the same file at
|
|
335
|
-
* once, and releasing "the file" released somebody else's hold.
|
|
336
|
-
*
|
|
337
|
-
* @param {string} claimId
|
|
338
|
-
* @returns {Promise<void>}
|
|
339
|
-
*/
|
|
340
|
-
async releaseFile(claimId) {
|
|
341
|
-
await this.#caller.call(Command.RELEASE_FILE, { claimId });
|
|
342
|
-
}
|
|
343
|
-
|
|
344
320
|
/**
|
|
345
321
|
* Live download figures for the progress display.
|
|
346
322
|
*
|
|
@@ -734,6 +734,10 @@ export async function* readFragments({
|
|
|
734
734
|
}
|
|
735
735
|
};
|
|
736
736
|
|
|
737
|
+
// How many times THIS piece has been asked for again after the store
|
|
738
|
+
// withdrew it. Reset once a piece is in hand, so the allowance is per piece.
|
|
739
|
+
let withdrawnRetries = 0;
|
|
740
|
+
|
|
737
741
|
try {
|
|
738
742
|
for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
|
|
739
743
|
if (cancellation.isCancelled()) {
|
|
@@ -986,8 +990,33 @@ export async function* readFragments({
|
|
|
986
990
|
|
|
987
991
|
if (!located) {
|
|
988
992
|
store.unpin(pieceIndex);
|
|
989
|
-
|
|
993
|
+
// THE CLAIM HAS JUST BEEN WITHDRAWN, so this is a wait and not a
|
|
994
|
+
// failure. The store drops a piece behind every read head, and a read
|
|
995
|
+
// that re-opens an input at byte 0 — which is every encoder restart —
|
|
996
|
+
// asks for exactly those pieces. Until 2026-09-12 this threw, ffmpeg
|
|
997
|
+
// read the empty body as the end of the file, and the encoder died and
|
|
998
|
+
// was restarted into the same emptiness: field, `Piece 0 is verified
|
|
999
|
+
// but absent from the store` answered every read for 92 minutes while
|
|
1000
|
+
// the viewer looked at a still picture.
|
|
1001
|
+
//
|
|
1002
|
+
// Going back one step re-runs this piece's whole path rather than a
|
|
1003
|
+
// shortened copy of it: the bitfield now says the piece is missing, so
|
|
1004
|
+
// the block above declares it, steers it onto the fastest holders and
|
|
1005
|
+
// waits for it, exactly as it does for a piece that was never here.
|
|
1006
|
+
// Once per piece — a second emptiness means the bytes are not coming
|
|
1007
|
+
// and the caller must hear so.
|
|
1008
|
+
if (withdrawnRetries === 0) {
|
|
1009
|
+
withdrawnRetries += 1;
|
|
1010
|
+
pieceIndex -= 1;
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
throw new Error(
|
|
1014
|
+
`Piece ${pieceIndex} was withdrawn from the store and did not come back.`
|
|
1015
|
+
);
|
|
990
1016
|
}
|
|
1017
|
+
// Counted per piece: a long read may legitimately meet this more than
|
|
1018
|
+
// once, and each piece is entitled to its own second chance.
|
|
1019
|
+
withdrawnRetries = 0;
|
|
991
1020
|
|
|
992
1021
|
let releasedThisPiece = false;
|
|
993
1022
|
// Remembered so the generator can drop it itself. The pin is taken here
|
|
@@ -11,11 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Two accommodations are needed, and both are deliberate:
|
|
13
13
|
*
|
|
14
|
-
* - **`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
-
* site for no observable gain.
|
|
14
|
+
* - **`prioritizeByteRange` stays synchronous.** It returns nothing the caller
|
|
15
|
+
* inspects, so the command is dispatched and not awaited. Awaiting it would
|
|
16
|
+
* mean touching every call site for no observable gain.
|
|
19
17
|
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
18
|
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
19
|
* get one derived from the source itself, so the identity stays stable
|
|
@@ -85,34 +83,6 @@ export class WorkerTorrentPool {
|
|
|
85
83
|
return this.#client.allowSpillBytes(bytes);
|
|
86
84
|
}
|
|
87
85
|
|
|
88
|
-
acquireFile(torrent, fileIndex) {
|
|
89
|
-
const sourceKey = torrent?.sourceKey;
|
|
90
|
-
if (!sourceKey) {
|
|
91
|
-
return () => undefined;
|
|
92
|
-
}
|
|
93
|
-
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
94
|
-
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
95
|
-
// to the worker, and if release arrives first the reader count drops to zero
|
|
96
|
-
// while a read is still running. The idle sweep then removes the torrent AND
|
|
97
|
-
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
98
|
-
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
99
|
-
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
100
|
-
// Chaining the release onto the acquire keeps them in order.
|
|
101
|
-
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
|
|
102
|
-
let released = false;
|
|
103
|
-
return () => {
|
|
104
|
-
if (released) {
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
released = true;
|
|
108
|
-
// Release the claim this call opened, not "the file" — waiting for the
|
|
109
|
-
// acquire is also what tells us which claim that is.
|
|
110
|
-
void acquired
|
|
111
|
-
.then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
|
|
112
|
-
.catch(() => undefined);
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
86
|
/**
|
|
117
87
|
* Bytes every torrent here has moved.
|
|
118
88
|
*
|
|
@@ -47,10 +47,6 @@
|
|
|
47
47
|
export const Command = {
|
|
48
48
|
/** Add (or join) a torrent; resolves when metadata is ready. */
|
|
49
49
|
ADD_SOURCE: "add-source",
|
|
50
|
-
/** Claim a file for reading, so it is not evicted while in use. */
|
|
51
|
-
ACQUIRE_FILE: "acquire-file",
|
|
52
|
-
/** Drop a claim; the worker applies its own idle-removal policy. */
|
|
53
|
-
RELEASE_FILE: "release-file",
|
|
54
50
|
/** File list and metadata for a source. */
|
|
55
51
|
LIST_FILES: "list-files",
|
|
56
52
|
/** Live download figures for the progress display. */
|
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
* with the torrent for this thread, which is exactly the problem being solved.
|
|
12
12
|
*
|
|
13
13
|
* The existing `TorrentPool` is reused wholesale rather than reimplemented. It
|
|
14
|
-
* already carries the parts that took field failures to get right —
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* threads.
|
|
14
|
+
* already carries the parts that took field failures to get right — idle
|
|
15
|
+
* removal, the global disk cap with LRU eviction, seek-aware piece
|
|
16
|
+
* prioritisation, adaptive upload — and none of that changes by moving threads.
|
|
18
17
|
*/
|
|
19
18
|
|
|
20
19
|
// MUST stay first: it redirects `webrtc-polyfill` to a JavaScript WebRTC stack
|
|
@@ -25,7 +24,6 @@ import { isUsableTorrentHandle } from "./handle-state.js";
|
|
|
25
24
|
import "./install-webrtc-shim.js";
|
|
26
25
|
import { parentPort, workerData } from "node:worker_threads";
|
|
27
26
|
import { createSendStream } from "./channel.js";
|
|
28
|
-
import { createFileClaims } from "./file-claims.js";
|
|
29
27
|
import { readFragments, supplyFiguresFor } from "./piece-reader.js";
|
|
30
28
|
import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
|
|
31
29
|
import {
|
|
@@ -36,6 +34,7 @@ import {
|
|
|
36
34
|
warmResumePosition
|
|
37
35
|
} from "./container-tracks.js";
|
|
38
36
|
import { fillFileInBackground } from "./background-fill.js";
|
|
37
|
+
import { demandFor } from "../download/registry.js";
|
|
39
38
|
import { CompletedFiles, completedFilesRoot } from "../files/CompletedFiles.js";
|
|
40
39
|
import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../files/piece-from-whole-file.js";
|
|
41
40
|
import { Command, Event } from "./protocol.js";
|
|
@@ -93,8 +92,6 @@ const torrentsByKey = new Map();
|
|
|
93
92
|
* @type {Map<string, { sourceType: string, source: string }>}
|
|
94
93
|
*/
|
|
95
94
|
const sourceRecipes = new Map();
|
|
96
|
-
/** File claims, each with its own identity — see `file-claims.js`. */
|
|
97
|
-
const fileClaims = createFileClaims();
|
|
98
95
|
/** In-flight reads, so a cancel can stop one mid-body. */
|
|
99
96
|
const readsById = new Map();
|
|
100
97
|
|
|
@@ -127,6 +124,26 @@ function log(message) {
|
|
|
127
124
|
* @param {string} sourceKey
|
|
128
125
|
* @returns {Promise<import("webtorrent").Torrent>}
|
|
129
126
|
*/
|
|
127
|
+
/**
|
|
128
|
+
* The torrent for a sourceKey IF there is one, and never one that has to be
|
|
129
|
+
* built to answer.
|
|
130
|
+
*
|
|
131
|
+
* The other half of the pair above, for the questions that are about something
|
|
132
|
+
* going away: a departure is not a reason to add anything, and the answer
|
|
133
|
+
* "there is nothing here" is a complete answer to them.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} sourceKey
|
|
136
|
+
* @returns {Promise<import("webtorrent").Torrent | null>}
|
|
137
|
+
*/
|
|
138
|
+
async function knownTorrent(sourceKey) {
|
|
139
|
+
const pending = torrentsByKey.get(sourceKey);
|
|
140
|
+
if (!pending) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const torrent = await pending.catch(() => null);
|
|
144
|
+
return isUsableTorrentHandle(torrent) ? torrent : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
async function requireTorrent(sourceKey) {
|
|
131
148
|
const pending = torrentsByKey.get(sourceKey);
|
|
132
149
|
if (!pending) {
|
|
@@ -241,15 +258,6 @@ async function streamRange({ id, sourceKey, fileIndex, start, end, windowBytes }
|
|
|
241
258
|
const sender = createSendStream({ port: parentPort, requestId: id });
|
|
242
259
|
readsById.set(id, sender);
|
|
243
260
|
|
|
244
|
-
// Hold the file for as long as this read runs. The caller also acquires it,
|
|
245
|
-
// but that acquire and its release are separate messages from another thread
|
|
246
|
-
// and can be reordered; this one cannot, because it lives entirely inside the
|
|
247
|
-
// read. Without it the idle sweep saw a zero reader count and removed the
|
|
248
|
-
// torrent AND its store mid-read — field 2026-08-02: "removed idle torrent
|
|
249
|
-
// ... and its store", after which every subsequent read hung and ffmpeg got
|
|
250
|
-
// an empty input.
|
|
251
|
-
const releaseRead = pool.acquireFile(torrent, fileIndex);
|
|
252
|
-
|
|
253
261
|
const rangeStart = start ?? 0;
|
|
254
262
|
const rangeEnd = end ?? file.length - 1;
|
|
255
263
|
|
|
@@ -290,7 +298,6 @@ async function streamRange({ id, sourceKey, fileIndex, start, end, windowBytes }
|
|
|
290
298
|
// Any fragment still awaiting confirmation will never get one now; settling
|
|
291
299
|
// it here releases its pin rather than leaking a held slot.
|
|
292
300
|
settleFragment(id);
|
|
293
|
-
releaseRead();
|
|
294
301
|
if (!failed) {
|
|
295
302
|
sender.end();
|
|
296
303
|
}
|
|
@@ -356,27 +363,6 @@ async function runCommand(command, params, id) {
|
|
|
356
363
|
}));
|
|
357
364
|
}
|
|
358
365
|
|
|
359
|
-
case Command.ACQUIRE_FILE: {
|
|
360
|
-
const torrent = await requireTorrent(params.sourceKey);
|
|
361
|
-
// Every acquire is its own claim. Sharing one per file meant the first
|
|
362
|
-
// reader to finish released the hold while others were still reading.
|
|
363
|
-
return fileClaims.open(
|
|
364
|
-
params.sourceKey,
|
|
365
|
-
params.fileIndex,
|
|
366
|
-
pool.acquireFile(torrent, params.fileIndex)
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
case Command.RELEASE_FILE: {
|
|
371
|
-
const released = fileClaims.close(params.claimId);
|
|
372
|
-
if (!released) {
|
|
373
|
-
// Not fatal — but it means a release arrived twice or after teardown,
|
|
374
|
-
// and silence here is what let the previous scheme look healthy.
|
|
375
|
-
log(`release for unknown file claim ${params.claimId}`);
|
|
376
|
-
}
|
|
377
|
-
return released;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
366
|
case Command.HELD_TORRENTS: {
|
|
381
367
|
// Which films this proxy has right now, and how much of each. Answered
|
|
382
368
|
// from the live client rather than from the main thread's map of
|
|
@@ -537,13 +523,18 @@ async function runCommand(command, params, id) {
|
|
|
537
523
|
}
|
|
538
524
|
|
|
539
525
|
case Command.PRIORITY_MAP: {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
526
|
+
// A MAP WITH NOTHING IN IT MUST NOT BRING A TORRENT BACK. It is what is
|
|
527
|
+
// said when the last viewer of a file leaves, which is also when the
|
|
528
|
+
// torrent may be on its way out — and `requireTorrent` rebuilds a dead
|
|
529
|
+
// handle from its recipe, so asking that way would re-add a torrent in
|
|
530
|
+
// order to be told that nothing is wanted of it.
|
|
531
|
+
const zones = Array.isArray(params.zones) ? params.zones : [];
|
|
532
|
+
const torrent = zones.length === 0
|
|
533
|
+
? await knownTorrent(params.sourceKey)
|
|
534
|
+
: await requireTorrent(params.sourceKey);
|
|
535
|
+
if (torrent) {
|
|
536
|
+
pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
|
|
537
|
+
}
|
|
547
538
|
return true;
|
|
548
539
|
}
|
|
549
540
|
|
|
@@ -590,7 +581,6 @@ async function runCommand(command, params, id) {
|
|
|
590
581
|
}
|
|
591
582
|
|
|
592
583
|
case Command.DESTROY_ALL: {
|
|
593
|
-
fileClaims.closeAll();
|
|
594
584
|
torrentsByKey.clear();
|
|
595
585
|
sourceRecipes.clear();
|
|
596
586
|
await pool.destroyAll();
|
|
@@ -718,7 +708,7 @@ setInterval(() => {
|
|
|
718
708
|
`${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/` +
|
|
719
709
|
`${stats.blockedByPins}/${stats.evictedOnRevise}/${stats.spillFailures}/` +
|
|
720
710
|
`${stats.evictedProtected}/${stats.demand?.unionPieces ?? 0}/${stats.admittedToDisk}/` +
|
|
721
|
-
`${stats.blocksAllocated}/${stats.blocksFree}/${stats.blocksReleased}`;
|
|
711
|
+
`${stats.blocksAllocated}/${stats.blocksFree}/${stats.blocksReleased}/${stats.withdrawn}`;
|
|
722
712
|
if (lastReported.get(stats.name) === signature) {
|
|
723
713
|
continue;
|
|
724
714
|
}
|
|
@@ -739,6 +729,13 @@ setInterval(() => {
|
|
|
739
729
|
(stats.admittedWithoutSlot > 0 ? ` to-disk-for-want-of-memory=${stats.admittedWithoutSlot}` : "") +
|
|
740
730
|
(stats.stillMs > 1000 ? ` nothing-moved-for=${Math.round(stats.stillMs / 1000)}s` : "") +
|
|
741
731
|
(stats.evictedOnRevise > 0 ? ` evictedOnRevise=${stats.evictedOnRevise}` : "") +
|
|
732
|
+
// How many pieces this store stopped being able to produce and said so,
|
|
733
|
+
// which is what makes the eviction's own bargain checkable: every one of
|
|
734
|
+
// these is a piece the torrent has been told to fetch again if it is ever
|
|
735
|
+
// wanted. A session where this climbs while reads keep succeeding is the
|
|
736
|
+
// bargain working; before 2026-09-12 the figure did not exist and the
|
|
737
|
+
// claim was never withdrawn at all.
|
|
738
|
+
(stats.withdrawn > 0 ? ` withdrawn=${stats.withdrawn}` : "") +
|
|
742
739
|
(stats.spillFailures > 0 ? ` spill-failures=${stats.spillFailures}` : "") +
|
|
743
740
|
(stats.outstanding > 0 ? ` outstanding=${stats.outstanding}` : "")
|
|
744
741
|
);
|
|
@@ -827,11 +824,11 @@ function describePieceBuffers() {
|
|
|
827
824
|
* @returns {void}
|
|
828
825
|
*/
|
|
829
826
|
function warmActiveFiles(sourceKey, torrent) {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
for (const fileIndex of
|
|
827
|
+
// The files anything is stated for — a viewer's own picture and soundtrack
|
|
828
|
+
// through the priority map, and the ends of a file that is open. It replaces
|
|
829
|
+
// a count of readers, which said the same thing by keeping a second copy of
|
|
830
|
+
// it.
|
|
831
|
+
for (const fileIndex of demandFor(torrent).register.files()) {
|
|
835
832
|
const key = `${sourceKey}:${fileIndex}`;
|
|
836
833
|
// A trigger that arrives while the previous pass is still walking is
|
|
837
834
|
// dropped, not queued. `verified` fires per piece, so on a fast download
|
|
@@ -1009,18 +1006,21 @@ async function keepWholeFiles() {
|
|
|
1009
1006
|
if (!infoHash || !Array.isArray(torrent.files)) {
|
|
1010
1007
|
continue;
|
|
1011
1008
|
}
|
|
1012
|
-
|
|
1009
|
+
// What anybody wants of this torrent. A file something is stated for is a
|
|
1010
|
+
// file somebody may be reading, and this is the same list the reader counts
|
|
1011
|
+
// used to give.
|
|
1012
|
+
const wanted = new Set(demandFor(torrent).register.files());
|
|
1013
1013
|
for (const [fileIndex, file] of torrent.files.entries()) {
|
|
1014
1014
|
const key = `${infoHash}/${fileIndex}`;
|
|
1015
1015
|
if (file?.done !== true || completedFiles.find(infoHash, fileIndex) || beingKept.has(key)) {
|
|
1016
1016
|
continue;
|
|
1017
1017
|
}
|
|
1018
|
-
// NOT WHILE
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
if (
|
|
1018
|
+
// NOT WHILE ANYBODY WANTS IT. Writing a film out is a read of the whole
|
|
1019
|
+
// of it and a write of the whole of it — a gigabyte and a half on the
|
|
1020
|
+
// file this was measured against — and doing that beside a viewer takes
|
|
1021
|
+
// the disk and the piece store from them for nothing they asked for. The
|
|
1022
|
+
// file is complete; it will still be complete when they leave.
|
|
1023
|
+
if (wanted.has(fileIndex)) {
|
|
1024
1024
|
continue;
|
|
1025
1025
|
}
|
|
1026
1026
|
beingKept.add(key);
|
|
@@ -1074,7 +1074,7 @@ async function keepWholeFiles() {
|
|
|
1074
1074
|
const isWhole =
|
|
1075
1075
|
torrent.done === true &&
|
|
1076
1076
|
torrent.files.every((unused, fileIndex) => completedFiles.find(infoHash, fileIndex) !== null);
|
|
1077
|
-
if (isWhole &&
|
|
1077
|
+
if (isWhole && wanted.size === 0) {
|
|
1078
1078
|
logger.info(
|
|
1079
1079
|
`whole files: "${torrent.name}" is downloaded whole and saved — removing the torrent, keeping the files`
|
|
1080
1080
|
);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file THE TWO ENDS OF A FILE ARE THE FILE'S, NOT A READ'S.
|
|
3
|
+
*
|
|
4
|
+
* A container keeps its directory at one end or the other: `ftyp` and an EBML
|
|
5
|
+
* header at the front, and for an MP4 that was not written for streaming the
|
|
6
|
+
* `moov` at the very back. Nothing can be read of such a file until they have
|
|
7
|
+
* arrived, and they go on being wanted for as long as it is open — a player
|
|
8
|
+
* asks for the file's shape again at every seek.
|
|
9
|
+
*
|
|
10
|
+
* Until 2.83.4 they were wanted by nobody. The prefetch that fetches them is an
|
|
11
|
+
* ordinary read, and a read withdraws what it states the moment it finishes, so
|
|
12
|
+
* the ends of an open film were held by nothing at all once the codec probe was
|
|
13
|
+
* done.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { TorrentPool, stateFileEdges, withdrawFileEdges } from "../services/torrent-pool.js";
|
|
19
|
+
import { demandFor, forgetTorrent } from "../services/download/registry.js";
|
|
20
|
+
import { Urgency } from "../services/demand/index.js";
|
|
21
|
+
|
|
22
|
+
/** A torrent of two files, of which the second is the film. */
|
|
23
|
+
function torrentOf({ length = 1_000_000 } = {}) {
|
|
24
|
+
return {
|
|
25
|
+
infoHash: `hash-${Math.random().toString(36).slice(2)}`,
|
|
26
|
+
pieceLength: 1024,
|
|
27
|
+
files: [
|
|
28
|
+
{ offset: 0, length: 4096, name: "notes.nfo" },
|
|
29
|
+
{ offset: 4096, length, name: "film.mkv" }
|
|
30
|
+
],
|
|
31
|
+
_selections: { _items: [] },
|
|
32
|
+
_select() {},
|
|
33
|
+
_deselect() {},
|
|
34
|
+
critical() {}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} torrent
|
|
40
|
+
* @returns {object[]}
|
|
41
|
+
*/
|
|
42
|
+
function edges(torrent) {
|
|
43
|
+
return demandFor(torrent)
|
|
44
|
+
.register.windows()
|
|
45
|
+
.filter((one) => String(one.claimant).startsWith("file-edges:"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("one byte is claimed at each end, which is one piece at each end", () => {
|
|
49
|
+
const torrent = torrentOf({ length: 1_000_000 });
|
|
50
|
+
try {
|
|
51
|
+
assert.equal(stateFileEdges(torrent, 1, Urgency.TAIL), true);
|
|
52
|
+
|
|
53
|
+
const stated = edges(torrent);
|
|
54
|
+
assert.equal(stated.length, 2);
|
|
55
|
+
const head = stated.find((one) => String(one.claimant).endsWith(":head"));
|
|
56
|
+
const tail = stated.find((one) => String(one.claimant).endsWith(":tail"));
|
|
57
|
+
// File-relative, like everything else stated about a file: the claim says
|
|
58
|
+
// "the first byte of this file" and "its last", and what a byte costs to
|
|
59
|
+
// fetch — a whole piece — is the swarm's business and nobody else's.
|
|
60
|
+
assert.deepEqual([head.byteStart, head.byteEnd], [0, 0]);
|
|
61
|
+
assert.deepEqual([tail.byteStart, tail.byteEnd], [999_999, 999_999]);
|
|
62
|
+
assert.equal(head.fileIndex, 1);
|
|
63
|
+
assert.equal(tail.fileIndex, 1);
|
|
64
|
+
} finally {
|
|
65
|
+
forgetTorrent(torrent);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("while somebody is waiting for them they are urgent, and afterwards they are kept", () => {
|
|
70
|
+
const torrent = torrentOf();
|
|
71
|
+
try {
|
|
72
|
+
// The playback plan cannot answer until the file has said what is in it,
|
|
73
|
+
// and a person is watching a loading screen for as long as that takes.
|
|
74
|
+
stateFileEdges(torrent, 1, Urgency.NEAR);
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
edges(torrent).map((one) => one.urgency),
|
|
77
|
+
[Urgency.NEAR, Urgency.NEAR]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Once it has answered, nobody is waiting — but a seek will want them
|
|
81
|
+
// again, so they stay stated at the level of something nobody is waiting
|
|
82
|
+
// for. Re-stating replaces; it does not pile a second claim on the first.
|
|
83
|
+
stateFileEdges(torrent, 1, Urgency.TAIL, { lower: true });
|
|
84
|
+
assert.equal(edges(torrent).length, 2);
|
|
85
|
+
assert.deepEqual(
|
|
86
|
+
edges(torrent).map((one) => one.urgency),
|
|
87
|
+
[Urgency.TAIL, Urgency.TAIL]
|
|
88
|
+
);
|
|
89
|
+
} finally {
|
|
90
|
+
forgetTorrent(torrent);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("each file of a torrent has its own ends", () => {
|
|
95
|
+
const torrent = torrentOf();
|
|
96
|
+
try {
|
|
97
|
+
stateFileEdges(torrent, 0, Urgency.TAIL);
|
|
98
|
+
stateFileEdges(torrent, 1, Urgency.NEAR);
|
|
99
|
+
assert.equal(edges(torrent).length, 4);
|
|
100
|
+
|
|
101
|
+
withdrawFileEdges(torrent, 0);
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
edges(torrent).map((one) => one.fileIndex),
|
|
104
|
+
[1, 1]
|
|
105
|
+
);
|
|
106
|
+
} finally {
|
|
107
|
+
forgetTorrent(torrent);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a file of unknown length has no ends to claim", () => {
|
|
112
|
+
const torrent = torrentOf();
|
|
113
|
+
try {
|
|
114
|
+
assert.equal(stateFileEdges(torrent, 7, Urgency.TAIL), false);
|
|
115
|
+
assert.equal(stateFileEdges({ files: [{ length: 0 }] }, 0, Urgency.TAIL), false);
|
|
116
|
+
assert.equal(edges(torrent).length, 0);
|
|
117
|
+
} finally {
|
|
118
|
+
forgetTorrent(torrent);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a map with nothing in it takes the ends back too", () => {
|
|
123
|
+
const torrent = torrentOf();
|
|
124
|
+
const applyPriorityMap = TorrentPool.prototype.applyPriorityMap;
|
|
125
|
+
try {
|
|
126
|
+
stateFileEdges(torrent, 1, Urgency.TAIL);
|
|
127
|
+
applyPriorityMap.call(null, torrent, 1, [{ from: 0, to: 600, priority: 100 }], 600);
|
|
128
|
+
assert.equal(edges(torrent).length, 2);
|
|
129
|
+
|
|
130
|
+
// Nobody wants this file any more, and its ends are kept only for as long
|
|
131
|
+
// as it is open.
|
|
132
|
+
applyPriorityMap.call(null, torrent, 1, [], 600);
|
|
133
|
+
assert.equal(edges(torrent).length, 0);
|
|
134
|
+
assert.deepEqual(demandFor(torrent).register.windows(), []);
|
|
135
|
+
} finally {
|
|
136
|
+
forgetTorrent(torrent);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("another file's departure leaves these ends alone", () => {
|
|
141
|
+
const torrent = torrentOf();
|
|
142
|
+
const applyPriorityMap = TorrentPool.prototype.applyPriorityMap;
|
|
143
|
+
try {
|
|
144
|
+
stateFileEdges(torrent, 0, Urgency.TAIL);
|
|
145
|
+
stateFileEdges(torrent, 1, Urgency.TAIL);
|
|
146
|
+
|
|
147
|
+
applyPriorityMap.call(null, torrent, 0, [], 600);
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
edges(torrent).map((one) => one.fileIndex),
|
|
151
|
+
[1, 1]
|
|
152
|
+
);
|
|
153
|
+
} finally {
|
|
154
|
+
forgetTorrent(torrent);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("a warm-up cannot take the urgency away from a plan that is waiting", () => {
|
|
159
|
+
// The two callers of the same ends: a playback plan, which somebody is
|
|
160
|
+
// watching a loading screen for, and a warm-up, which by its whole purpose
|
|
161
|
+
// nobody is waiting for. The warm-up fires off the files beside the picture
|
|
162
|
+
// without awaiting them, so it can arrive second — and lowering the plan's
|
|
163
|
+
// level there would put a person behind a film somebody else is watching.
|
|
164
|
+
const torrent = torrentOf();
|
|
165
|
+
try {
|
|
166
|
+
stateFileEdges(torrent, 1, Urgency.NEAR);
|
|
167
|
+
stateFileEdges(torrent, 1, Urgency.TAIL);
|
|
168
|
+
|
|
169
|
+
assert.deepEqual(
|
|
170
|
+
edges(torrent).map((one) => one.urgency),
|
|
171
|
+
[Urgency.NEAR, Urgency.NEAR]
|
|
172
|
+
);
|
|
173
|
+
} finally {
|
|
174
|
+
forgetTorrent(torrent);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the read that is over is what puts them back down", () => {
|
|
179
|
+
const torrent = torrentOf();
|
|
180
|
+
try {
|
|
181
|
+
stateFileEdges(torrent, 1, Urgency.NEAR);
|
|
182
|
+
stateFileEdges(torrent, 1, Urgency.TAIL, { lower: true });
|
|
183
|
+
|
|
184
|
+
assert.deepEqual(
|
|
185
|
+
edges(torrent).map((one) => one.urgency),
|
|
186
|
+
[Urgency.TAIL, Urgency.TAIL]
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
forgetTorrent(torrent);
|
|
190
|
+
}
|
|
191
|
+
});
|