@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A read whose piece is withdrawn under it waits, rather than failing.
|
|
3
|
+
*
|
|
4
|
+
* The store drops a piece once every reader is past it, and the claim is
|
|
5
|
+
* withdrawn with it, so the piece is fetched again. A read that meets the gap in
|
|
6
|
+
* between must therefore WAIT — until 2026-09-12 it threw, ffmpeg read the empty
|
|
7
|
+
* body as the end of the file, the encoder died, and the plan restarted it into
|
|
8
|
+
* the same emptiness: 2432 starts in 23 minutes and a viewer looking at a still
|
|
9
|
+
* picture for 92 minutes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import { readFragments } from "../services/torrent-worker/piece-reader.js";
|
|
19
|
+
import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
|
|
20
|
+
|
|
21
|
+
const PIECE = 1024;
|
|
22
|
+
const TOTAL = 4 * PIECE;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A torrent of four pieces over a real store, whose `reside` can be made to
|
|
26
|
+
* answer "gone" for a chosen piece a chosen number of times — which is what the
|
|
27
|
+
* store does between dropping a piece and the swarm bringing it back.
|
|
28
|
+
*
|
|
29
|
+
* @param {{ emptyFor: number, times: number }} gap
|
|
30
|
+
*/
|
|
31
|
+
async function torrentWithAGap({ emptyFor, times }) {
|
|
32
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "withdrawal-read-"));
|
|
33
|
+
const store = new SharedPieceStore(PIECE, {
|
|
34
|
+
length: TOTAL,
|
|
35
|
+
memoryBytes: 64 * PIECE,
|
|
36
|
+
path: directory,
|
|
37
|
+
name: "test",
|
|
38
|
+
files: [{ offset: 0, length: TOTAL, name: "file.bin" }]
|
|
39
|
+
});
|
|
40
|
+
for (let index = 0; index < 4; index += 1) {
|
|
41
|
+
const piece = Buffer.alloc(PIECE);
|
|
42
|
+
for (let at = 0; at < PIECE; at += 1) {
|
|
43
|
+
piece[at] = (index * PIECE + at) % 251;
|
|
44
|
+
}
|
|
45
|
+
await new Promise((resolve, reject) => {
|
|
46
|
+
store.put(index, piece, (error) => (error ? reject(error) : resolve()));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const held = new Set([0, 1, 2, 3]);
|
|
51
|
+
let left = times;
|
|
52
|
+
/** How many times the piece was asked of the store at all. */
|
|
53
|
+
let asked = 0;
|
|
54
|
+
// The gap is injected at the one method that answers "can you produce this
|
|
55
|
+
// piece" — subclassed rather than wrapped, because `findSharedStore` walks
|
|
56
|
+
// the chain looking for the real class and would find the real one behind a
|
|
57
|
+
// facade.
|
|
58
|
+
store.reside = async function resideWithAGap(index) {
|
|
59
|
+
if (index !== emptyFor) {
|
|
60
|
+
return SharedPieceStore.prototype.reside.call(this, index);
|
|
61
|
+
}
|
|
62
|
+
asked += 1;
|
|
63
|
+
if (left > 0) {
|
|
64
|
+
left -= 1;
|
|
65
|
+
// THE CLAIM GOES WITH THE BYTES. That is what makes the wait a wait for a
|
|
66
|
+
// download and not a wait for nothing, and it is what the withdrawal now
|
|
67
|
+
// does in production.
|
|
68
|
+
held.delete(index);
|
|
69
|
+
// Back a moment later, as the swarm brings it: the reader's own wait ends
|
|
70
|
+
// on the torrent's `verified` event.
|
|
71
|
+
setImmediate(() => {
|
|
72
|
+
held.add(index);
|
|
73
|
+
torrent.emit("verified", index);
|
|
74
|
+
});
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return SharedPieceStore.prototype.reside.call(this, index);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const torrent = Object.assign(new EventEmitter(), {
|
|
81
|
+
pieceLength: PIECE,
|
|
82
|
+
store,
|
|
83
|
+
bitfield: { get: (index) => held.has(index) },
|
|
84
|
+
files: [{ offset: 0, length: TOTAL, name: "file.bin" }],
|
|
85
|
+
select() {},
|
|
86
|
+
critical() {}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
torrent,
|
|
91
|
+
clean: async () => {
|
|
92
|
+
store.destroy(() => undefined);
|
|
93
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
94
|
+
},
|
|
95
|
+
asksFor: () => asked
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read a range as the worker does. */
|
|
100
|
+
async function readRange(torrent, start, end) {
|
|
101
|
+
const collected = [];
|
|
102
|
+
for await (const fragment of readFragments({
|
|
103
|
+
torrent,
|
|
104
|
+
fileIndex: 0,
|
|
105
|
+
start,
|
|
106
|
+
end,
|
|
107
|
+
cancellation: { isCancelled: () => false }
|
|
108
|
+
})) {
|
|
109
|
+
const source = fragment.buffer
|
|
110
|
+
? Buffer.from(fragment.buffer, fragment.offset, fragment.length)
|
|
111
|
+
: Buffer.alloc(0);
|
|
112
|
+
collected.push(Buffer.from(source));
|
|
113
|
+
fragment.release();
|
|
114
|
+
}
|
|
115
|
+
return Buffer.concat(collected);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function expectedBytes(absoluteStart, length) {
|
|
119
|
+
const expected = Buffer.alloc(length);
|
|
120
|
+
for (let at = 0; at < length; at += 1) {
|
|
121
|
+
expected[at] = (absoluteStart + at) % 251;
|
|
122
|
+
}
|
|
123
|
+
return expected;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test("a piece withdrawn once is asked for again and the read completes", async () => {
|
|
127
|
+
// Piece 0 is the case the field met: an encoder restart re-opens its input at
|
|
128
|
+
// byte 0, and byte 0 is behind every read head by then.
|
|
129
|
+
const { torrent, clean, asksFor } = await torrentWithAGap({ emptyFor: 0, times: 1 });
|
|
130
|
+
try {
|
|
131
|
+
const bytes = await readRange(torrent, 0, 2 * PIECE - 1);
|
|
132
|
+
assert.deepEqual(bytes, expectedBytes(0, 2 * PIECE), "the read returns the film, not an empty body");
|
|
133
|
+
assert.equal(asksFor(), 2, "the piece is asked for a second time, not given up on");
|
|
134
|
+
} finally {
|
|
135
|
+
await clean();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a piece that does not come back ends the read with a named error", async () => {
|
|
140
|
+
const { torrent, clean } = await torrentWithAGap({ emptyFor: 0, times: 5 });
|
|
141
|
+
try {
|
|
142
|
+
await assert.rejects(
|
|
143
|
+
() => readRange(torrent, 0, PIECE - 1),
|
|
144
|
+
// NOT "verified but absent": the claim has been withdrawn, so the honest
|
|
145
|
+
// statement is that the bytes did not come back.
|
|
146
|
+
/piece 0 was withdrawn from the store and did not come back/i,
|
|
147
|
+
"a second emptiness is a failure the caller must hear about"
|
|
148
|
+
);
|
|
149
|
+
} finally {
|
|
150
|
+
await clean();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("every piece of a long read gets its own second chance", async () => {
|
|
155
|
+
// The allowance is per piece: a read of many pieces may legitimately meet the
|
|
156
|
+
// gap more than once, and one exhausted allowance must not condemn the rest.
|
|
157
|
+
const { torrent, clean } = await torrentWithAGap({ emptyFor: 2, times: 1 });
|
|
158
|
+
try {
|
|
159
|
+
const bytes = await readRange(torrent, 0, 4 * PIECE - 1);
|
|
160
|
+
assert.deepEqual(bytes, expectedBytes(0, 4 * PIECE));
|
|
161
|
+
} finally {
|
|
162
|
+
await clean();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file WHAT THE BROWSER IS TOLD IS IN A TORRENT.
|
|
3
|
+
*
|
|
4
|
+
* This is the one place the product answers it. The browser used to answer it
|
|
5
|
+
* as well — a list of video extensions in its parser and a second, shorter pair
|
|
6
|
+
* inside its picker — and the three had already diverged: measured 2026-09-12,
|
|
7
|
+
* `.dat` was offered there as video and not counted here, which also decides
|
|
8
|
+
* whether a sidecar whose name matches nothing can belong to the only video
|
|
9
|
+
* present.
|
|
10
|
+
*
|
|
11
|
+
* So the shape of this answer is a contract with the page, and the page shows
|
|
12
|
+
* what it is given without looking at a name.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { handleApiSourceFilesGet } from "../routes/api/sources/files/get.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A request, a reply and the torrent behind them.
|
|
21
|
+
*
|
|
22
|
+
* @param {Array<{ path: string, length: number }>} files
|
|
23
|
+
* @returns {{ req: object, reply: object, sent: { body: object | null, code: number }, deps: object }}
|
|
24
|
+
*/
|
|
25
|
+
function harness(files) {
|
|
26
|
+
const sent = { body: null, code: 200 };
|
|
27
|
+
const reply = {
|
|
28
|
+
code(value) {
|
|
29
|
+
sent.code = value;
|
|
30
|
+
return reply;
|
|
31
|
+
},
|
|
32
|
+
send(body) {
|
|
33
|
+
sent.body = body;
|
|
34
|
+
return reply;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const deps = {
|
|
38
|
+
sourceRegistry: { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:abc" }) },
|
|
39
|
+
torrentPool: {
|
|
40
|
+
async getTorrent() {
|
|
41
|
+
return { name: "Drifters", infoHash: "abc", files };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return { req: { params: { sourceKey: "key" }, query: {} }, reply, sent, deps };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("the files come back in reading order, each saying what it is", () => {
|
|
49
|
+
const { req, reply, sent, deps } = harness([
|
|
50
|
+
{ path: "Drifters/ep 10.mkv", length: 9 },
|
|
51
|
+
{ path: "Drifters/ep 2.mkv", length: 9 },
|
|
52
|
+
{ path: "Drifters/Rus Sound/ep 2.mka", length: 2 },
|
|
53
|
+
{ path: "Drifters/notes.nfo", length: 1 }
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
return handleApiSourceFilesGet(req, reply, deps).then(() => {
|
|
57
|
+
assert.equal(sent.code, 200);
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
sent.body.files.map((file) => [file.relativePath, file.kind]),
|
|
60
|
+
[
|
|
61
|
+
// Runs of digits compare as numbers, so 2 comes before 10; the torrent's
|
|
62
|
+
// own order is whatever the tool that made it chose.
|
|
63
|
+
["ep 2.mkv", "video"],
|
|
64
|
+
["ep 10.mkv", "video"],
|
|
65
|
+
["notes.nfo", "other"],
|
|
66
|
+
["Rus Sound/ep 2.mka", "audio"]
|
|
67
|
+
]
|
|
68
|
+
);
|
|
69
|
+
// Relative to the torrent root: WebTorrent prefixes its own name to every
|
|
70
|
+
// path, and stripping it here means one rule in the product rather than two
|
|
71
|
+
// that can disagree.
|
|
72
|
+
assert.ok(sent.body.files.every((file) => !file.relativePath.startsWith("Drifters/")));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("each picture is named with what belongs to it, by index", () => {
|
|
77
|
+
const { req, reply, sent, deps } = harness([
|
|
78
|
+
{ path: "Drifters/ep 1.mkv", length: 9 },
|
|
79
|
+
{ path: "Drifters/Rus Sound/ep 1.mka", length: 2 },
|
|
80
|
+
{ path: "Drifters/Sub/ep 1.ass", length: 1 },
|
|
81
|
+
{ path: "Drifters/ep 2.mkv", length: 9 }
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
return handleApiSourceFilesGet(req, reply, deps).then(() => {
|
|
85
|
+
assert.deepEqual(sent.body.items, [
|
|
86
|
+
{ fileIndex: 0, audio: [1], subtitles: [2], images: [] },
|
|
87
|
+
{ fileIndex: 3, audio: [], subtitles: [], images: [] }
|
|
88
|
+
]);
|
|
89
|
+
// The files themselves are in the list above; saying them twice is how two
|
|
90
|
+
// copies of one fact start.
|
|
91
|
+
assert.ok(sent.body.items.every((item) => item.audio.every((one) => Number.isInteger(one))));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a torrent whose metadata has not arrived says so and lists nothing", () => {
|
|
96
|
+
const { req, reply, sent, deps } = harness([]);
|
|
97
|
+
|
|
98
|
+
return handleApiSourceFilesGet(req, reply, deps).then(() => {
|
|
99
|
+
assert.deepEqual(sent.body.files, []);
|
|
100
|
+
assert.deepEqual(sent.body.items, []);
|
|
101
|
+
assert.equal(sent.body.name, "Drifters");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -19,11 +19,11 @@ import { handleStreamGet } from "../routes/stream/get.js";
|
|
|
19
19
|
* Minimal stand-ins for the parts of Fastify and the pool this route touches.
|
|
20
20
|
*
|
|
21
21
|
* @param {{ method: string, range?: string }} request
|
|
22
|
-
* @returns {{ req: object, reply: object, sent: object, opened: string[],
|
|
22
|
+
* @returns {{ req: object, reply: object, sent: object, opened: string[], state: object }}
|
|
23
23
|
*/
|
|
24
24
|
function harness({ method, range }) {
|
|
25
25
|
const opened = [];
|
|
26
|
-
const state = {
|
|
26
|
+
const state = { prioritized: [] };
|
|
27
27
|
|
|
28
28
|
const sent = { code: 200, headers: {}, body: undefined, called: false };
|
|
29
29
|
const reply = {
|
|
@@ -71,10 +71,6 @@ function harness({ method, range }) {
|
|
|
71
71
|
async getTorrent() {
|
|
72
72
|
return { files: [file], sourceKey: "key" };
|
|
73
73
|
},
|
|
74
|
-
acquireFile() {
|
|
75
|
-
state.claims += 1;
|
|
76
|
-
return () => undefined;
|
|
77
|
-
},
|
|
78
74
|
prioritizeByteRange(_torrent, fileIndex, byteStart, _windowBytes, options) {
|
|
79
75
|
state.prioritized.push({ byteStart, wholeFileRead: options?.wholeFileRead === true });
|
|
80
76
|
}
|
|
@@ -96,7 +92,7 @@ test("HEAD reports the size without opening a read", async () => {
|
|
|
96
92
|
await handleStreamGet(req, reply, deps);
|
|
97
93
|
|
|
98
94
|
assert.deepEqual(opened, [], "HEAD started a read of the file");
|
|
99
|
-
assert.
|
|
95
|
+
assert.deepEqual(state.prioritized, [], "HEAD asked the swarm for a read it never made");
|
|
100
96
|
// The real size, not the zero Fastify substitutes for an empty payload — the
|
|
101
97
|
// keyframe index reads this header and treats 0 as "no index".
|
|
102
98
|
assert.equal(sent.headers["content-length"], "5869669065");
|
|
@@ -182,5 +178,5 @@ test("a file this proxy does not have whole still goes to the torrent", async ()
|
|
|
182
178
|
const { req, reply, state, deps } = harness({ method: "GET", range: "bytes=0-99" });
|
|
183
179
|
deps.torrentPool.wholeFiles = new Map([["other/7", { path: "/nowhere", length: 1, name: "x" }]]);
|
|
184
180
|
await handleStreamGet(req, reply, deps);
|
|
185
|
-
assert.equal(state.
|
|
181
|
+
assert.equal(state.prioritized.length, 1, "the ordinary path was not taken");
|
|
186
182
|
});
|
|
@@ -11,17 +11,30 @@
|
|
|
11
11
|
* The rule is not a limit on connections. While anybody is reading, every one is
|
|
12
12
|
* worth keeping: the one that has delivered nothing yet may deliver next.
|
|
13
13
|
*
|
|
14
|
-
* AND IT IS
|
|
15
|
-
* five seconds whether anybody was
|
|
16
|
-
* earlier answered no — its edges still being read,
|
|
17
|
-
* It left the swarm with 741 connections let go,
|
|
18
|
-
* rejoining
|
|
19
|
-
* swarm had been fetching. Playback did not start at
|
|
14
|
+
* AND IT IS ASKED OF WHAT HAS BEEN STATED, never of a count of readers. The
|
|
15
|
+
* first version asked every five seconds whether anybody was READING, and a
|
|
16
|
+
* torrent added three seconds earlier answered no — its edges still being read,
|
|
17
|
+
* its plan still being built. It left the swarm with 741 connections let go,
|
|
18
|
+
* and nothing rejoined it: rejoining waited for a reader, and the reader was
|
|
19
|
+
* waiting for the header the swarm had been fetching. Playback did not start at
|
|
20
|
+
* all.
|
|
21
|
+
*
|
|
22
|
+
* The question now is whether anything is WANTED of it, which the priority map,
|
|
23
|
+
* the ends of an open file and a stopped read all answer — and which is true
|
|
24
|
+
* from the moment a torrent is opened.
|
|
20
25
|
*/
|
|
21
26
|
|
|
22
27
|
import test from "node:test";
|
|
23
28
|
import assert from "node:assert/strict";
|
|
24
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
isWanted,
|
|
31
|
+
leaveSwarm,
|
|
32
|
+
rejoinSwarm,
|
|
33
|
+
stateFileEdges,
|
|
34
|
+
swarmDecisionFor
|
|
35
|
+
} from "../services/torrent-pool.js";
|
|
36
|
+
import { demandFor, forgetTorrent } from "../services/download/registry.js";
|
|
37
|
+
import { Urgency } from "../services/demand/index.js";
|
|
25
38
|
|
|
26
39
|
/**
|
|
27
40
|
* A torrent that records what was done to it. Only the surface the rule
|
|
@@ -81,12 +94,81 @@ test("the reader that comes back takes the swarm with it", () => {
|
|
|
81
94
|
assert.equal(rejoinSwarm(torrent), false, "a torrent already in its swarm was resumed again");
|
|
82
95
|
});
|
|
83
96
|
|
|
84
|
-
test("a torrent
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// that can ask it.
|
|
97
|
+
test("a torrent whose metadata has not arrived is wanted, whatever is stated", () => {
|
|
98
|
+
// THE FIELD FAILURE OF 2026-09-11, as a question rather than as a mechanism.
|
|
99
|
+
// A torrent being added has no list of files, so nothing can name a byte of
|
|
100
|
+
// it — and it is being fetched precisely because somebody asked for it.
|
|
89
101
|
const torrent = torrentWith(103);
|
|
90
|
-
|
|
91
|
-
assert.equal(torrent
|
|
102
|
+
torrent.files = [];
|
|
103
|
+
assert.equal(isWanted(torrent), true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a torrent with files and nothing stated for them is wanted by nobody", () => {
|
|
107
|
+
const torrent = torrentWith(4);
|
|
108
|
+
try {
|
|
109
|
+
assert.equal(isWanted(torrent), false);
|
|
110
|
+
} finally {
|
|
111
|
+
forgetTorrent(torrent);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("anything stated makes it wanted, and the last withdrawal ends that", () => {
|
|
116
|
+
const torrent = torrentWith(4);
|
|
117
|
+
try {
|
|
118
|
+
// The ends of a file being opened, which is the first thing said about a
|
|
119
|
+
// torrent anybody has picked and the reason it stays in its swarm through
|
|
120
|
+
// the seconds when nothing else can say anything about it.
|
|
121
|
+
stateFileEdges(torrent, 0, Urgency.TAIL);
|
|
122
|
+
assert.equal(isWanted(torrent), true);
|
|
123
|
+
|
|
124
|
+
const { register } = demandFor(torrent);
|
|
125
|
+
register.withdraw("file-edges:0:head");
|
|
126
|
+
assert.equal(isWanted(torrent), true, "one end of it is still wanted");
|
|
127
|
+
register.withdraw("file-edges:0:tail");
|
|
128
|
+
assert.equal(isWanted(torrent), false);
|
|
129
|
+
} finally {
|
|
130
|
+
forgetTorrent(torrent);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a torrent that has been destroyed is wanted by nobody", () => {
|
|
135
|
+
const torrent = torrentWith(1);
|
|
136
|
+
torrent.destroyed = true;
|
|
137
|
+
assert.equal(isWanted(torrent), false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a torrent something is wanted of keeps its swarm and is off the clock", () => {
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
swarmDecisionFor({ wanted: true, everWanted: true }),
|
|
143
|
+
{ swarm: "take", onTheClock: false }
|
|
144
|
+
);
|
|
145
|
+
assert.deepEqual(
|
|
146
|
+
swarmDecisionFor({ wanted: true, everWanted: false }),
|
|
147
|
+
{ swarm: "take", onTheClock: false }
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a swarm is let go on a DEPARTURE, never on a beginning", () => {
|
|
152
|
+
// The whole of the 2.83.1 failure in one line: a torrent that has never been
|
|
153
|
+
// wanted is one being opened — its metadata has just landed, the file list is
|
|
154
|
+
// on its way to the person choosing, and nothing has had a chance to state
|
|
155
|
+
// anything about it yet. Taking its swarm away there destroyed 741
|
|
156
|
+
// connections it needed a minute later.
|
|
157
|
+
assert.deepEqual(
|
|
158
|
+
swarmDecisionFor({ wanted: false, everWanted: false }),
|
|
159
|
+
{ swarm: "leave alone", onTheClock: true }
|
|
160
|
+
);
|
|
161
|
+
assert.deepEqual(
|
|
162
|
+
swarmDecisionFor({ wanted: false, everWanted: true }),
|
|
163
|
+
{ swarm: "let go", onTheClock: true }
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("whatever happens to the swarm, an unwanted torrent is on the idle clock", () => {
|
|
168
|
+
// Including the one nobody has ever wanted: a file list fetched and never
|
|
169
|
+
// played used to be held for the life of the process, because the only thing
|
|
170
|
+
// that started the clock was a reader letting go.
|
|
171
|
+
for (const everWanted of [true, false]) {
|
|
172
|
+
assert.equal(swarmDecisionFor({ wanted: false, everWanted }).onTheClock, true);
|
|
173
|
+
}
|
|
92
174
|
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What one torrent says is in it: its pictures, what belongs to each, and
|
|
3
|
+
* the order a person reads them in.
|
|
4
|
+
*
|
|
5
|
+
* The fixtures are real releases. `Drifters` ships twelve episodes in the root
|
|
6
|
+
* with a Russian soundtrack per episode under `Rus Sound/` and a subtitle file
|
|
7
|
+
* per episode under `Sub/[group]/`; the single-film shape ships one `.mkv` with
|
|
8
|
+
* a dub whose name has nothing in common with it. Both are the cases the
|
|
9
|
+
* pairing rules beside this were written against, and this file is about what
|
|
10
|
+
* is built ON them — the grouping, the order and the leftovers.
|
|
11
|
+
*
|
|
12
|
+
* Measured on the survey collection, 2026-09-12, and recorded here because it
|
|
13
|
+
* is what says the grouping is not an invention: 134 torrents, 44 of them with
|
|
14
|
+
* more than one picture, 1357 items and 421 files paired to one. Not a single
|
|
15
|
+
* file was paired to two pictures, and in all 1357 the grouping agreed with the
|
|
16
|
+
* per-picture call it replaces.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { TorrentContents, contentsOf } from "../services/torrent/Contents.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The Drifters torrent, as WebTorrent reports it: every path prefixed with the
|
|
25
|
+
* torrent's own name, and the episodes in the order the tool that made it chose
|
|
26
|
+
* — which is not the order anybody reads them in.
|
|
27
|
+
*
|
|
28
|
+
* @param {number[]} episodes
|
|
29
|
+
* @returns {Array<{ path: string, name: string, length: number }>}
|
|
30
|
+
*/
|
|
31
|
+
function driftersFiles(episodes) {
|
|
32
|
+
const files = [];
|
|
33
|
+
const push = (relative, length) => {
|
|
34
|
+
const name = relative.slice(relative.lastIndexOf("/") + 1);
|
|
35
|
+
files.push({ path: `Drifters/${relative}`, name, length });
|
|
36
|
+
};
|
|
37
|
+
for (const episode of episodes) {
|
|
38
|
+
const stem = `[HorribleSubs] Drifters - ${String(episode).padStart(2, "0")} [1080p]`;
|
|
39
|
+
push(`Sub/[Stan WarHammer & Nesitach]/${stem}.ass`, 29_000);
|
|
40
|
+
push(`Rus Sound/${stem}.mka`, 30_000_000);
|
|
41
|
+
push(`${stem}.mkv`, 566_000_000);
|
|
42
|
+
}
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("every picture is an item, with its own sound and subtitles on it", () => {
|
|
47
|
+
const contents = new TorrentContents({ files: driftersFiles([1, 2]), name: "Drifters" });
|
|
48
|
+
|
|
49
|
+
assert.equal(contents.videoCount, 2);
|
|
50
|
+
assert.equal(contents.items.length, 2);
|
|
51
|
+
const [first, second] = contents.items;
|
|
52
|
+
assert.match(first.name, / - 01 /);
|
|
53
|
+
assert.match(second.name, / - 02 /);
|
|
54
|
+
assert.deepEqual(
|
|
55
|
+
first.audio.map((file) => file.name),
|
|
56
|
+
["[HorribleSubs] Drifters - 01 [1080p].mka"]
|
|
57
|
+
);
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
first.subtitles.map((file) => file.name),
|
|
60
|
+
["[HorribleSubs] Drifters - 01 [1080p].ass"]
|
|
61
|
+
);
|
|
62
|
+
// The sound of episode 1 must never appear on the picture of episode 2: the
|
|
63
|
+
// wrong pairing is silent everywhere downstream.
|
|
64
|
+
assert.deepEqual(
|
|
65
|
+
second.audio.map((file) => file.name),
|
|
66
|
+
["[HorribleSubs] Drifters - 02 [1080p].mka"]
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("items come out in the order a person reads them, not the torrent's own", () => {
|
|
71
|
+
// A real release lists its episodes 08, 06, 07, 01 — the order of whatever
|
|
72
|
+
// tool made the torrent, routinely by size.
|
|
73
|
+
const contents = new TorrentContents({ files: driftersFiles([8, 6, 10, 2]), name: "Drifters" });
|
|
74
|
+
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
contents.items.map((item) => item.name.match(/ - (\d+) /)[1]),
|
|
77
|
+
["02", "06", "08", "10"]
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("runs of digits are compared as numbers, so 2 comes before 10", () => {
|
|
82
|
+
const files = [
|
|
83
|
+
{ path: "Show/ep10.mkv", name: "ep10.mkv", length: 9 },
|
|
84
|
+
{ path: "Show/ep2.mkv", name: "ep2.mkv", length: 9 }
|
|
85
|
+
];
|
|
86
|
+
const contents = new TorrentContents({ files, name: "Show" });
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
contents.items.map((item) => item.name),
|
|
90
|
+
["ep2.mkv", "ep10.mkv"]
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an item keeps the torrent's own index, whatever order it is read in", () => {
|
|
95
|
+
const contents = new TorrentContents({ files: driftersFiles([8, 2]), name: "Drifters" });
|
|
96
|
+
|
|
97
|
+
// Episode 2 is read first and is the fifth file of the torrent.
|
|
98
|
+
assert.equal(contents.items[0].fileIndex, 5);
|
|
99
|
+
assert.equal(contents.items[1].fileIndex, 2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a file is answered for by the item it belongs to, as a picture or as a part", () => {
|
|
103
|
+
const contents = new TorrentContents({ files: driftersFiles([1]), name: "Drifters" });
|
|
104
|
+
const [item] = contents.items;
|
|
105
|
+
|
|
106
|
+
assert.equal(contents.itemOf(item.fileIndex), item);
|
|
107
|
+
assert.equal(contents.itemOf(item.audio[0].fileIndex), item);
|
|
108
|
+
assert.equal(contents.itemOf(item.subtitles[0].fileIndex), item);
|
|
109
|
+
assert.equal(contents.itemOf(404), null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("what belongs beside one picture is answered from what was decided once", () => {
|
|
113
|
+
const contents = new TorrentContents({ files: driftersFiles([1, 2]), name: "Drifters" });
|
|
114
|
+
const [first] = contents.items;
|
|
115
|
+
|
|
116
|
+
const beside = contents.sidecarsOf(first.fileIndex);
|
|
117
|
+
assert.deepEqual(beside.audio, first.audio);
|
|
118
|
+
assert.deepEqual(beside.subtitles, first.subtitles);
|
|
119
|
+
// Asked about a file that is not a picture, the answer is nothing rather than
|
|
120
|
+
// the item that file happens to sit in: the question is "what goes beside
|
|
121
|
+
// this picture", and a soundtrack is not one.
|
|
122
|
+
assert.deepEqual(contents.sidecarsOf(first.audio[0].fileIndex), {
|
|
123
|
+
audio: [],
|
|
124
|
+
subtitles: [],
|
|
125
|
+
images: []
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a torrent with one picture takes the dub whose name has nothing in common", () => {
|
|
130
|
+
const files = [
|
|
131
|
+
{ path: "Film/Film.2024.1080p.mkv", name: "Film.2024.1080p.mkv", length: 4_000_000_000 },
|
|
132
|
+
{ path: "Film/Rus Sound/dub.mka", name: "dub.mka", length: 30_000_000 }
|
|
133
|
+
];
|
|
134
|
+
const contents = new TorrentContents({ files, name: "Film" });
|
|
135
|
+
|
|
136
|
+
assert.equal(contents.items.length, 1);
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
contents.items[0].audio.map((file) => file.name),
|
|
139
|
+
["dub.mka"]
|
|
140
|
+
);
|
|
141
|
+
assert.deepEqual(contents.leftovers, []);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("what belongs to no picture is listed as such, and nothing is lost", () => {
|
|
145
|
+
// `.nfo` is the release's own note and belongs to nothing. A `.txt` would
|
|
146
|
+
// NOT do here: the pairing layer counts it among the subtitle formats, and a
|
|
147
|
+
// torrent with one picture takes every sidecar there is.
|
|
148
|
+
const files = [
|
|
149
|
+
{ path: "Film/Film.mkv", name: "Film.mkv", length: 9 },
|
|
150
|
+
{ path: "Film/release.nfo", name: "release.nfo", length: 1 },
|
|
151
|
+
{ path: "Film/Screens/unrelated.jpg", name: "unrelated.jpg", length: 1 }
|
|
152
|
+
];
|
|
153
|
+
const contents = new TorrentContents({ files, name: "Film" });
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
contents.leftovers.map((file) => file.relativePath),
|
|
157
|
+
["release.nfo", "Screens/unrelated.jpg"]
|
|
158
|
+
);
|
|
159
|
+
const accounted =
|
|
160
|
+
contents.items.length +
|
|
161
|
+
contents.items.reduce(
|
|
162
|
+
(count, item) => count + item.audio.length + item.subtitles.length + item.images.length,
|
|
163
|
+
0
|
|
164
|
+
) +
|
|
165
|
+
contents.leftovers.length;
|
|
166
|
+
assert.equal(accounted, files.length);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a torrent whose metadata has not arrived says it holds nothing", () => {
|
|
170
|
+
const contents = new TorrentContents({ files: [], name: "" });
|
|
171
|
+
|
|
172
|
+
assert.deepEqual(contents.items, []);
|
|
173
|
+
assert.deepEqual(contents.leftovers, []);
|
|
174
|
+
assert.equal(contents.videoCount, 0);
|
|
175
|
+
assert.equal(contents.itemOf(0), null);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the answer is worked out once per torrent, and again when its files arrive", () => {
|
|
179
|
+
const torrent = { name: "Drifters", files: [] };
|
|
180
|
+
|
|
181
|
+
const empty = contentsOf(torrent);
|
|
182
|
+
assert.equal(contentsOf(torrent), empty, "asking twice must not work it out twice");
|
|
183
|
+
|
|
184
|
+
// A magnet has no files until its metadata lands, which happens exactly once.
|
|
185
|
+
torrent.files = driftersFiles([1]);
|
|
186
|
+
const full = contentsOf(torrent);
|
|
187
|
+
assert.notEqual(full, empty);
|
|
188
|
+
assert.equal(full.items.length, 1);
|
|
189
|
+
assert.equal(contentsOf(torrent), full);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("folders order before names, so seasons stay together", () => {
|
|
193
|
+
const files = [
|
|
194
|
+
{ path: "Show/Season 2/ep 1.mkv", length: 9 },
|
|
195
|
+
{ path: "Show/Season 10/ep 1.mkv", length: 9 },
|
|
196
|
+
{ path: "Show/Season 1/ep 2.mkv", length: 9 },
|
|
197
|
+
{ path: "Show/Season 1/ep 1.mkv", length: 9 }
|
|
198
|
+
];
|
|
199
|
+
const contents = new TorrentContents({ files, name: "Show" });
|
|
200
|
+
|
|
201
|
+
assert.deepEqual(
|
|
202
|
+
contents.items.map((item) => item.relativePath),
|
|
203
|
+
["Season 1/ep 1.mkv", "Season 1/ep 2.mkv", "Season 2/ep 1.mkv", "Season 10/ep 1.mkv"]
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("every file is described by what it is, in the order a person reads them", () => {
|
|
208
|
+
// What the browser is given. It used to answer this itself — a list of video
|
|
209
|
+
// extensions in its parser and a second, shorter pair inside its picker —
|
|
210
|
+
// against this one, and the three had already diverged.
|
|
211
|
+
const files = [
|
|
212
|
+
{ path: "Film/notes.nfo", length: 1 },
|
|
213
|
+
{ path: "Film/cover.jpg", length: 2 },
|
|
214
|
+
{ path: "Film/film.mkv", length: 9 },
|
|
215
|
+
{ path: "Film/Rus Sound/dub.mka", length: 3 },
|
|
216
|
+
{ path: "Film/Sub/film.ass", length: 1 }
|
|
217
|
+
];
|
|
218
|
+
const contents = new TorrentContents({ files, name: "Film" });
|
|
219
|
+
|
|
220
|
+
assert.deepEqual(
|
|
221
|
+
contents.files().map((file) => [file.relativePath, file.kind]),
|
|
222
|
+
[
|
|
223
|
+
["cover.jpg", "image"],
|
|
224
|
+
["film.mkv", "video"],
|
|
225
|
+
["notes.nfo", "other"],
|
|
226
|
+
["Rus Sound/dub.mka", "audio"],
|
|
227
|
+
["Sub/film.ass", "subtitle"]
|
|
228
|
+
]
|
|
229
|
+
);
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
contents.files().map((file) => file.fileIndex),
|
|
232
|
+
[1, 2, 0, 3, 4],
|
|
233
|
+
"the torrent's own numbers travel with them"
|
|
234
|
+
);
|
|
235
|
+
});
|