@torrent-tv/proxy 2.83.3 → 2.83.4
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 +19 -0
- package/package.json +1 -1
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -0
- package/research/priority-map-is-the-truth-2026-09-12.md +207 -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/hls-session-manager.js +3 -68
- 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 +302 -100
- package/services/torrent-worker/client.js +0 -24
- package/services/torrent-worker/pool-adapter.js +3 -33
- package/services/torrent-worker/protocol.js +0 -4
- package/services/torrent-worker/worker.js +52 -59
- package/test/file-edges.test.js +191 -0
- package/test/priority-map-emptied.test.js +207 -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/services/torrent-worker/file-claims.js +0 -91
- package/test/file-claims.test.js +0 -64
|
@@ -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();
|
|
@@ -827,11 +817,11 @@ function describePieceBuffers() {
|
|
|
827
817
|
* @returns {void}
|
|
828
818
|
*/
|
|
829
819
|
function warmActiveFiles(sourceKey, torrent) {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
for (const fileIndex of
|
|
820
|
+
// The files anything is stated for — a viewer's own picture and soundtrack
|
|
821
|
+
// through the priority map, and the ends of a file that is open. It replaces
|
|
822
|
+
// a count of readers, which said the same thing by keeping a second copy of
|
|
823
|
+
// it.
|
|
824
|
+
for (const fileIndex of demandFor(torrent).register.files()) {
|
|
835
825
|
const key = `${sourceKey}:${fileIndex}`;
|
|
836
826
|
// A trigger that arrives while the previous pass is still walking is
|
|
837
827
|
// dropped, not queued. `verified` fires per piece, so on a fast download
|
|
@@ -1009,18 +999,21 @@ async function keepWholeFiles() {
|
|
|
1009
999
|
if (!infoHash || !Array.isArray(torrent.files)) {
|
|
1010
1000
|
continue;
|
|
1011
1001
|
}
|
|
1012
|
-
|
|
1002
|
+
// What anybody wants of this torrent. A file something is stated for is a
|
|
1003
|
+
// file somebody may be reading, and this is the same list the reader counts
|
|
1004
|
+
// used to give.
|
|
1005
|
+
const wanted = new Set(demandFor(torrent).register.files());
|
|
1013
1006
|
for (const [fileIndex, file] of torrent.files.entries()) {
|
|
1014
1007
|
const key = `${infoHash}/${fileIndex}`;
|
|
1015
1008
|
if (file?.done !== true || completedFiles.find(infoHash, fileIndex) || beingKept.has(key)) {
|
|
1016
1009
|
continue;
|
|
1017
1010
|
}
|
|
1018
|
-
// NOT WHILE
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
if (
|
|
1011
|
+
// NOT WHILE ANYBODY WANTS IT. Writing a film out is a read of the whole
|
|
1012
|
+
// of it and a write of the whole of it — a gigabyte and a half on the
|
|
1013
|
+
// file this was measured against — and doing that beside a viewer takes
|
|
1014
|
+
// the disk and the piece store from them for nothing they asked for. The
|
|
1015
|
+
// file is complete; it will still be complete when they leave.
|
|
1016
|
+
if (wanted.has(fileIndex)) {
|
|
1024
1017
|
continue;
|
|
1025
1018
|
}
|
|
1026
1019
|
beingKept.add(key);
|
|
@@ -1074,7 +1067,7 @@ async function keepWholeFiles() {
|
|
|
1074
1067
|
const isWhole =
|
|
1075
1068
|
torrent.done === true &&
|
|
1076
1069
|
torrent.files.every((unused, fileIndex) => completedFiles.find(infoHash, fileIndex) !== null);
|
|
1077
|
-
if (isWhole &&
|
|
1070
|
+
if (isWhole && wanted.size === 0) {
|
|
1078
1071
|
logger.info(
|
|
1079
1072
|
`whole files: "${torrent.name}" is downloaded whole and saved — removing the torrent, keeping the files`
|
|
1080
1073
|
);
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file WHEN NOBODY WANTS A FILE ANY MORE, THE MAP SAYS SO.
|
|
3
|
+
*
|
|
4
|
+
* The map is the only statement of what is wanted, and until 2.83.4 it could
|
|
5
|
+
* make only one of the two statements it exists for. A file whose viewers had
|
|
6
|
+
* gone was deleted from the orchestrator's own memory and published nowhere, so
|
|
7
|
+
* the bands it had stated on the swarm's behalf stood until the torrent itself
|
|
8
|
+
* was removed — read from the demand register, a film nobody had watched for an
|
|
9
|
+
* hour was indistinguishable from one being watched now.
|
|
10
|
+
*
|
|
11
|
+
* Two halves, and both are here: the side that builds the map has to say it,
|
|
12
|
+
* and the side that receives it has to act on it without needing the file's
|
|
13
|
+
* length, the torrent's list, or the torrent to exist at all.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { PriorityOrchestrator } from "../services/priority/PriorityOrchestrator.js";
|
|
19
|
+
import { Viewers } from "../services/viewer/Viewers.js";
|
|
20
|
+
import { viewersOf } from "../services/viewer/Viewer.js";
|
|
21
|
+
import { TorrentPool } from "../services/torrent-pool.js";
|
|
22
|
+
import { demandFor, forgetTorrent } from "../services/download/registry.js";
|
|
23
|
+
import { Urgency } from "../services/demand/index.js";
|
|
24
|
+
|
|
25
|
+
const STALE_AFTER_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A session, as much of one as the orchestrator reads.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ id: string, fileIndex?: number }} params
|
|
31
|
+
* @returns {object}
|
|
32
|
+
*/
|
|
33
|
+
function outputOf({ id, fileIndex = 0 }) {
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
outputKey: `out:${id}`,
|
|
37
|
+
sourceKey: "source-1",
|
|
38
|
+
fileIndex,
|
|
39
|
+
file: { key: `film-${fileIndex}`, durationSeconds: 600 }
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The real orchestrator, with everything it publishes kept.
|
|
45
|
+
*
|
|
46
|
+
* @param {object[]} sessions
|
|
47
|
+
* @returns {{ priority: PriorityOrchestrator, viewers: Viewers, published: object[], publish: (sessions?: object[]) => void }}
|
|
48
|
+
*/
|
|
49
|
+
function over(sessions) {
|
|
50
|
+
const published = [];
|
|
51
|
+
const viewers = new Viewers();
|
|
52
|
+
const priority = new PriorityOrchestrator({
|
|
53
|
+
publish: (one) => published.push(one),
|
|
54
|
+
viewersOf: (session) => viewersOf(session),
|
|
55
|
+
allowanceFor: () => 10
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
priority,
|
|
59
|
+
viewers,
|
|
60
|
+
published,
|
|
61
|
+
publish: (live = sessions) =>
|
|
62
|
+
priority.publishFor({ sessionGroups: [live], staleAfterMs: STALE_AFTER_MS })
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("a file whose viewers have all gone is published as wanting nothing", () => {
|
|
67
|
+
const picture = outputOf({ id: "pic" });
|
|
68
|
+
const { viewers, published, publish } = over([picture]);
|
|
69
|
+
const person = viewers.of(picture, "p");
|
|
70
|
+
person.moveTo(300);
|
|
71
|
+
publish();
|
|
72
|
+
assert.ok(published.at(-1).zones.length > 0, "somebody is watching, so something is wanted");
|
|
73
|
+
|
|
74
|
+
// They stop answering. The session is still there — it outlives them by half
|
|
75
|
+
// an hour — so this is the case that used to say nothing at all.
|
|
76
|
+
person.seen(Date.now() - STALE_AFTER_MS * 2);
|
|
77
|
+
publish();
|
|
78
|
+
|
|
79
|
+
const last = published.at(-1);
|
|
80
|
+
assert.deepEqual(last.zones, []);
|
|
81
|
+
assert.equal(last.sourceKey, "source-1");
|
|
82
|
+
assert.equal(last.fileIndex, 0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a file whose session has gone is published as wanting nothing, once", () => {
|
|
86
|
+
const picture = outputOf({ id: "pic" });
|
|
87
|
+
const { viewers, published, publish } = over([picture]);
|
|
88
|
+
viewers.of(picture, "p").moveTo(300);
|
|
89
|
+
publish();
|
|
90
|
+
const said = published.length;
|
|
91
|
+
|
|
92
|
+
// The session is disposed: it is not among the live ones any more.
|
|
93
|
+
publish([]);
|
|
94
|
+
assert.deepEqual(published.at(-1).zones, []);
|
|
95
|
+
assert.equal(published.length, said + 1);
|
|
96
|
+
|
|
97
|
+
// And it is not repeated on every pass afterwards — the file is gone from
|
|
98
|
+
// this class's memory, and a departure is said once.
|
|
99
|
+
publish([]);
|
|
100
|
+
publish([]);
|
|
101
|
+
assert.equal(published.length, said + 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an unchanged map is still not republished", () => {
|
|
105
|
+
const picture = outputOf({ id: "pic" });
|
|
106
|
+
const { viewers, published, publish } = over([picture]);
|
|
107
|
+
viewers.of(picture, "p").moveTo(300);
|
|
108
|
+
publish();
|
|
109
|
+
const said = published.length;
|
|
110
|
+
publish();
|
|
111
|
+
publish();
|
|
112
|
+
assert.equal(published.length, said, "the downloading rebuilds its requests on every one");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** A torrent that is nothing but one file of a known length. */
|
|
116
|
+
function torrentOf({ length = 1_000_000 } = {}) {
|
|
117
|
+
return {
|
|
118
|
+
infoHash: `hash-${Math.random().toString(36).slice(2)}`,
|
|
119
|
+
pieceLength: 1024,
|
|
120
|
+
files: [{ offset: 0, length, name: "film.mkv" }],
|
|
121
|
+
_selections: { _items: [] },
|
|
122
|
+
_select() {},
|
|
123
|
+
_deselect() {},
|
|
124
|
+
critical() {}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const applyPriorityMap = TorrentPool.prototype.applyPriorityMap;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {object} torrent
|
|
132
|
+
* @returns {object[]}
|
|
133
|
+
*/
|
|
134
|
+
function stated(torrent) {
|
|
135
|
+
return demandFor(torrent)
|
|
136
|
+
.register.windows()
|
|
137
|
+
.filter((one) => String(one.claimant).startsWith("priority-map:"));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test("a map with nothing in it withdraws everything that file had stated", () => {
|
|
141
|
+
const torrent = torrentOf();
|
|
142
|
+
try {
|
|
143
|
+
applyPriorityMap.call(null, torrent, 0, [
|
|
144
|
+
{ from: 0, to: 100, priority: 100 },
|
|
145
|
+
{ from: 100, to: 600, priority: 50 }
|
|
146
|
+
], 600);
|
|
147
|
+
assert.equal(stated(torrent).length, 2);
|
|
148
|
+
|
|
149
|
+
applyPriorityMap.call(null, torrent, 0, [], 600);
|
|
150
|
+
assert.equal(stated(torrent).length, 0);
|
|
151
|
+
} finally {
|
|
152
|
+
forgetTorrent(torrent);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("it withdraws that file's bands and nobody else's", () => {
|
|
157
|
+
const torrent = torrentOf();
|
|
158
|
+
try {
|
|
159
|
+
const { register } = demandFor(torrent);
|
|
160
|
+
applyPriorityMap.call(null, torrent, 0, [{ from: 0, to: 600, priority: 100 }], 600);
|
|
161
|
+
// A read stopped on a piece right now, which only a read can say, and the
|
|
162
|
+
// background fill, which the pool states for itself. Neither is the map's
|
|
163
|
+
// to withdraw.
|
|
164
|
+
register.state({
|
|
165
|
+
claimant: "read-7:blocked",
|
|
166
|
+
fileIndex: 0,
|
|
167
|
+
byteStart: 0,
|
|
168
|
+
byteEnd: 999,
|
|
169
|
+
urgency: Urgency.BLOCKED
|
|
170
|
+
});
|
|
171
|
+
register.state({
|
|
172
|
+
claimant: "background-fill:0",
|
|
173
|
+
fileIndex: 0,
|
|
174
|
+
byteStart: 1000,
|
|
175
|
+
byteEnd: 9999,
|
|
176
|
+
urgency: Urgency.TAIL
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
applyPriorityMap.call(null, torrent, 0, [], 600);
|
|
180
|
+
|
|
181
|
+
assert.equal(stated(torrent).length, 0);
|
|
182
|
+
assert.deepEqual(
|
|
183
|
+
register.windows().map((one) => one.claimant).sort(),
|
|
184
|
+
["background-fill:0", "read-7:blocked"]
|
|
185
|
+
);
|
|
186
|
+
} finally {
|
|
187
|
+
forgetTorrent(torrent);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a departure is answered even when nothing else about the file is known", () => {
|
|
192
|
+
// The length, the duration and the torrent's own list are what the ordinary
|
|
193
|
+
// path needs to turn seconds into bytes. A departure needs none of them, and
|
|
194
|
+
// it arrives exactly when they are going away: the map that says it is
|
|
195
|
+
// published as the last viewer leaves.
|
|
196
|
+
const torrent = torrentOf();
|
|
197
|
+
try {
|
|
198
|
+
applyPriorityMap.call(null, torrent, 0, [{ from: 0, to: 600, priority: 100 }], 600);
|
|
199
|
+
assert.equal(stated(torrent).length, 1);
|
|
200
|
+
|
|
201
|
+
torrent.files = [];
|
|
202
|
+
applyPriorityMap.call(null, torrent, 0, [], 0);
|
|
203
|
+
assert.equal(stated(torrent).length, 0);
|
|
204
|
+
} finally {
|
|
205
|
+
forgetTorrent(torrent);
|
|
206
|
+
}
|
|
207
|
+
});
|