@torrent-tv/proxy 2.72.2 → 2.73.1
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 +16 -0
- package/docs/container-architecture.md +66 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +19 -0
- package/routes/stream/get.js +18 -1
- package/server.js +25 -1
- package/services/container/AviContainer.js +36 -0
- package/services/container/Container.js +41 -0
- package/services/container/MatroskaContainer.js +186 -1
- package/services/container/Mp4Container.js +158 -29
- package/services/container-index/ebml-reader.js +30 -0
- package/services/controllers/SubtitleController.js +1 -1
- package/services/hls-session-manager.js +167 -37
- package/services/orchestrators/ContainerOrchestrator.js +22 -0
- package/services/orchestrators/SubtitleOrchestrator.js +64 -5
- package/services/torrent-pool.js +46 -0
- package/services/torrent-worker/client.js +21 -0
- package/services/torrent-worker/container-tracks.js +134 -0
- package/services/torrent-worker/fastest-wires.js +29 -6
- package/services/torrent-worker/piece-reader.js +11 -4
- package/services/torrent-worker/pool-adapter.js +35 -0
- package/services/torrent-worker/protocol.js +12 -0
- package/services/torrent-worker/subtitle-cues.js +15 -0
- package/services/torrent-worker/worker.js +45 -1
- package/test/container-media-info.test.js +228 -0
- package/test/resume-warm.test.js +39 -0
- package/test/subtitle-cue-source.test.js +104 -0
- package/test/tail-duplication.test.js +48 -4
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a file declares about ITSELF — format, duration, and where its own
|
|
3
|
+
* timeline begins — read from its header by the container layer.
|
|
4
|
+
*
|
|
5
|
+
* These fixtures are built byte by byte rather than produced by ffmpeg, on
|
|
6
|
+
* purpose: a test that runs a real encoder measures the machine it runs on, and
|
|
7
|
+
* two such tests in this suite have failed four times in one day for exactly
|
|
8
|
+
* that reason (roadmap item 54). The numbers here are checked against ffmpeg
|
|
9
|
+
* ONCE, by hand, and the result is recorded rather than re-measured on every
|
|
10
|
+
* run — 2026-09-03, a Matroska file offset by 0.130435 s: ffmpeg reported
|
|
11
|
+
* `Duration: 00:00:02.13, start: 0.130000` and this reader answered
|
|
12
|
+
* `durationSeconds 2.131, startTimeSeconds 0.13`, which is the same number at
|
|
13
|
+
* the precision each prints. The same file as MP4: `start: 0.000000` from
|
|
14
|
+
* ffmpeg, 0 from this reader.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
|
|
20
|
+
import { MatroskaContainer } from "../services/container/MatroskaContainer.js";
|
|
21
|
+
import { Mp4Container } from "../services/container/Mp4Container.js";
|
|
22
|
+
import { AviContainer } from "../services/container/AviContainer.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An EBML element: its id bytes, a four-byte size, then the payload.
|
|
26
|
+
*
|
|
27
|
+
* @param {number[]} idBytes
|
|
28
|
+
* @param {Buffer} payload
|
|
29
|
+
* @returns {Buffer}
|
|
30
|
+
*/
|
|
31
|
+
function ebml(idBytes, payload) {
|
|
32
|
+
const size = Buffer.alloc(4);
|
|
33
|
+
// Four-byte size form: `0001xxxx` in the leading byte marks the width.
|
|
34
|
+
size.writeUInt32BE(payload.length);
|
|
35
|
+
size[0] |= 0x10;
|
|
36
|
+
return Buffer.concat([Buffer.from(idBytes), size, payload]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {number} value @param {number} bytes @returns {Buffer} */
|
|
40
|
+
function uint(value, bytes) {
|
|
41
|
+
const out = Buffer.alloc(bytes);
|
|
42
|
+
out.writeUIntBE(value, 0, bytes);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {number} value @returns {Buffer} */
|
|
47
|
+
function float64(value) {
|
|
48
|
+
const out = Buffer.alloc(8);
|
|
49
|
+
out.writeDoubleBE(value);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ID_EBML_HEADER = [0x1a, 0x45, 0xdf, 0xa3];
|
|
54
|
+
const ID_SEGMENT = [0x18, 0x53, 0x80, 0x67];
|
|
55
|
+
const ID_SEEK_HEAD = [0x11, 0x4d, 0x9b, 0x74];
|
|
56
|
+
const ID_SEEK = [0x4d, 0xbb];
|
|
57
|
+
const ID_SEEK_ID = [0x53, 0xab];
|
|
58
|
+
const ID_SEEK_POSITION = [0x53, 0xac];
|
|
59
|
+
const ID_INFO = [0x15, 0x49, 0xa9, 0x66];
|
|
60
|
+
const ID_TIMESTAMP_SCALE = [0x2a, 0xd7, 0xb1];
|
|
61
|
+
const ID_DURATION = [0x44, 0x89];
|
|
62
|
+
const ID_CLUSTER = [0x1f, 0x43, 0xb6, 0x75];
|
|
63
|
+
const ID_TIMESTAMP = [0xe7];
|
|
64
|
+
const ID_VOID = [0xec];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A reader over a buffer, in the shape the container layer takes.
|
|
68
|
+
*
|
|
69
|
+
* @param {Buffer} bytes
|
|
70
|
+
* @returns {(start: number, end: number) => Promise<Buffer>}
|
|
71
|
+
*/
|
|
72
|
+
function readerOver(bytes) {
|
|
73
|
+
return async (start, end) => bytes.subarray(start, Math.min(end + 1, bytes.length));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("a Matroska file states its duration in ticks and its start in the first cluster", async () => {
|
|
77
|
+
const info = ebml(ID_INFO, Buffer.concat([
|
|
78
|
+
ebml(ID_TIMESTAMP_SCALE, uint(1_000_000, 3)),
|
|
79
|
+
// 2131 ticks of a millisecond each.
|
|
80
|
+
ebml(ID_DURATION, float64(2131))
|
|
81
|
+
]));
|
|
82
|
+
const cluster = ebml(ID_CLUSTER, ebml(ID_TIMESTAMP, uint(130, 1)));
|
|
83
|
+
const file = Buffer.concat([
|
|
84
|
+
ebml(ID_EBML_HEADER, Buffer.alloc(4)),
|
|
85
|
+
ebml(ID_SEGMENT, Buffer.concat([info, cluster]))
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const container = new MatroskaContainer({ readRange: readerOver(file), fileSize: file.length });
|
|
89
|
+
const read = await container.readMediaInfo();
|
|
90
|
+
|
|
91
|
+
assert.equal(read.format, "matroska");
|
|
92
|
+
assert.ok(Math.abs(read.durationSeconds - 2.131) < 1e-9, `duration was ${read.durationSeconds}`);
|
|
93
|
+
assert.ok(Math.abs(read.startTimeSeconds - 0.13) < 1e-9, `start was ${read.startTimeSeconds}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a cluster past the head window is found through the SeekHead", async () => {
|
|
97
|
+
// Everything before the cluster is padded past the 64 KB the head read covers,
|
|
98
|
+
// which is the case this second path exists for: a file whose Tracks element
|
|
99
|
+
// is large enough to push the first cluster out of reach.
|
|
100
|
+
const info = ebml(ID_INFO, ebml(ID_TIMESTAMP_SCALE, uint(1_000_000, 3)));
|
|
101
|
+
const padding = ebml(ID_VOID, Buffer.alloc(70 * 1024));
|
|
102
|
+
const cluster = ebml(ID_CLUSTER, ebml(ID_TIMESTAMP, uint(2500, 2)));
|
|
103
|
+
// The SeekHead is written first, so its own length is known before the
|
|
104
|
+
// position it names can be computed — build it with a placeholder, measure,
|
|
105
|
+
// then write the real position.
|
|
106
|
+
const seekHeadFor = (position) => ebml(ID_SEEK_HEAD, ebml(ID_SEEK, Buffer.concat([
|
|
107
|
+
ebml(ID_SEEK_ID, Buffer.from(ID_CLUSTER)),
|
|
108
|
+
ebml(ID_SEEK_POSITION, uint(position, 4))
|
|
109
|
+
])));
|
|
110
|
+
const seekHeadLength = seekHeadFor(0).length;
|
|
111
|
+
const clusterPosition = seekHeadLength + info.length + padding.length;
|
|
112
|
+
const segmentPayload = Buffer.concat([seekHeadFor(clusterPosition), info, padding, cluster]);
|
|
113
|
+
const file = Buffer.concat([
|
|
114
|
+
ebml(ID_EBML_HEADER, Buffer.alloc(4)),
|
|
115
|
+
ebml(ID_SEGMENT, segmentPayload)
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const container = new MatroskaContainer({ readRange: readerOver(file), fileSize: file.length });
|
|
119
|
+
const read = await container.readMediaInfo();
|
|
120
|
+
|
|
121
|
+
assert.ok(Math.abs(read.startTimeSeconds - 2.5) < 1e-9, `start was ${read.startTimeSeconds}`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a Matroska file that declares no duration says so, rather than saying zero", async () => {
|
|
125
|
+
const cluster = ebml(ID_CLUSTER, ebml(ID_TIMESTAMP, uint(0, 1)));
|
|
126
|
+
const file = Buffer.concat([
|
|
127
|
+
ebml(ID_EBML_HEADER, Buffer.alloc(4)),
|
|
128
|
+
ebml(ID_SEGMENT, cluster)
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const container = new MatroskaContainer({ readRange: readerOver(file), fileSize: file.length });
|
|
132
|
+
const read = await container.readMediaInfo();
|
|
133
|
+
|
|
134
|
+
assert.equal(read.durationSeconds, null);
|
|
135
|
+
assert.equal(read.startTimeSeconds, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* An ISO/IEC 14496-12 box.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} type
|
|
142
|
+
* @param {Buffer} payload
|
|
143
|
+
* @returns {Buffer}
|
|
144
|
+
*/
|
|
145
|
+
function box(type, payload) {
|
|
146
|
+
const header = Buffer.alloc(8);
|
|
147
|
+
header.writeUInt32BE(payload.length + 8);
|
|
148
|
+
header.write(type, 4, "latin1");
|
|
149
|
+
return Buffer.concat([header, payload]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
test("an MP4 states its duration in mvhd and its start in an empty edit", async () => {
|
|
153
|
+
const mvhd = box("mvhd", Buffer.concat([
|
|
154
|
+
Buffer.alloc(4), // version 0 + flags
|
|
155
|
+
Buffer.alloc(8), // creation, modification
|
|
156
|
+
uint(1000, 4), // timescale: ticks per second
|
|
157
|
+
uint(2000, 4), // duration: two seconds
|
|
158
|
+
Buffer.alloc(80)
|
|
159
|
+
]));
|
|
160
|
+
const elst = box("elst", Buffer.concat([
|
|
161
|
+
Buffer.alloc(4), // version 0 + flags
|
|
162
|
+
uint(1, 4), // one entry
|
|
163
|
+
uint(130, 4), // segment_duration: 0.130 s at the movie timescale
|
|
164
|
+
Buffer.from([0xff, 0xff, 0xff, 0xff]), // media_time -1: an EMPTY edit
|
|
165
|
+
uint(0x00010000, 4) // media_rate 1.0
|
|
166
|
+
]));
|
|
167
|
+
const trak = box("trak", box("edts", elst));
|
|
168
|
+
const file = Buffer.concat([
|
|
169
|
+
box("ftyp", Buffer.from("isom", "latin1")),
|
|
170
|
+
box("moov", Buffer.concat([mvhd, trak]))
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
const container = new Mp4Container({ readRange: readerOver(file), fileSize: file.length });
|
|
174
|
+
const read = await container.readMediaInfo();
|
|
175
|
+
|
|
176
|
+
assert.equal(read.format, "mp4");
|
|
177
|
+
assert.ok(Math.abs(read.durationSeconds - 2) < 1e-9, `duration was ${read.durationSeconds}`);
|
|
178
|
+
assert.ok(Math.abs(read.startTimeSeconds - 0.13) < 1e-9, `start was ${read.startTimeSeconds}`);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("an MP4 with no edit list begins at zero, and that is an answer", async () => {
|
|
182
|
+
const mvhd = box("mvhd", Buffer.concat([
|
|
183
|
+
Buffer.alloc(4),
|
|
184
|
+
Buffer.alloc(8),
|
|
185
|
+
uint(600, 4),
|
|
186
|
+
uint(1200, 4),
|
|
187
|
+
Buffer.alloc(80)
|
|
188
|
+
]));
|
|
189
|
+
const file = Buffer.concat([
|
|
190
|
+
box("ftyp", Buffer.from("isom", "latin1")),
|
|
191
|
+
box("moov", mvhd)
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const container = new Mp4Container({ readRange: readerOver(file), fileSize: file.length });
|
|
195
|
+
const read = await container.readMediaInfo();
|
|
196
|
+
|
|
197
|
+
assert.ok(Math.abs(read.durationSeconds - 2) < 1e-9, `duration was ${read.durationSeconds}`);
|
|
198
|
+
assert.equal(read.startTimeSeconds, 0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("an AVI states its length as microseconds per frame times the frame count", async () => {
|
|
202
|
+
const avih = Buffer.concat([
|
|
203
|
+
Buffer.from("avih", "latin1"),
|
|
204
|
+
uint(56, 4),
|
|
205
|
+
Buffer.from(new Uint8Array(new Uint32Array([
|
|
206
|
+
40_000, // dwMicroSecPerFrame: 25 fps
|
|
207
|
+
0, 0, 0,
|
|
208
|
+
50 // dwTotalFrames: two seconds of them
|
|
209
|
+
]).buffer)),
|
|
210
|
+
Buffer.alloc(36)
|
|
211
|
+
]);
|
|
212
|
+
const file = Buffer.concat([
|
|
213
|
+
Buffer.from("RIFF", "latin1"),
|
|
214
|
+
uint(0, 4),
|
|
215
|
+
Buffer.from("AVI ", "latin1"),
|
|
216
|
+
Buffer.from("LIST", "latin1"),
|
|
217
|
+
uint(avih.length + 4, 4),
|
|
218
|
+
Buffer.from("hdrl", "latin1"),
|
|
219
|
+
avih
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
const container = new AviContainer({ readRange: readerOver(file), fileSize: file.length });
|
|
223
|
+
const read = await container.readMediaInfo();
|
|
224
|
+
|
|
225
|
+
assert.equal(read.format, "avi");
|
|
226
|
+
assert.ok(Math.abs(read.durationSeconds - 2) < 1e-9, `duration was ${read.durationSeconds}`);
|
|
227
|
+
assert.equal(read.startTimeSeconds, 0);
|
|
228
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Where a viewer's resume position falls in the file, in bytes.
|
|
3
|
+
*
|
|
4
|
+
* The warm-up fetches a file's two edges, because that is what the codec probe
|
|
5
|
+
* reads. The region the VIEWER will resume at was asked for by nobody until the
|
|
6
|
+
* encoder opened its input — field 2026-09-03, 53 s after the Retry button on a
|
|
7
|
+
* cold torrent, and the piece it then needed took another 46.3 s to arrive.
|
|
8
|
+
* Turning the position into an offset is the only arithmetic in that path, so it
|
|
9
|
+
* is the only part with anything to get wrong.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
|
|
15
|
+
import { resumeByteOffset } from "../services/torrent-worker/container-tracks.js";
|
|
16
|
+
|
|
17
|
+
test("a position halfway through a film is halfway through its file", () => {
|
|
18
|
+
assert.equal(resumeByteOffset(1000, 100, 50), 500);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("the field case lands where the encoder went looking", () => {
|
|
22
|
+
// The measured session: 171.338 s into a 23:41 episode of 541 MB, and the
|
|
23
|
+
// read that blocked was at 63 MB.
|
|
24
|
+
const at = resumeByteOffset(541 * 1024 * 1024, 23 * 60 + 41, 171.338);
|
|
25
|
+
const megabytes = at / (1024 * 1024);
|
|
26
|
+
assert.ok(megabytes > 60 && megabytes < 68, `landed at ${megabytes.toFixed(1)}MB`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a position past the end reads the end, rather than past it", () => {
|
|
30
|
+
const length = 1000;
|
|
31
|
+
const at = resumeByteOffset(length, 100, 10_000);
|
|
32
|
+
assert.equal(at, length - 1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("nothing is known, nothing is guessed", () => {
|
|
36
|
+
assert.equal(resumeByteOffset(0, 100, 50), 0, "no file length");
|
|
37
|
+
assert.equal(resumeByteOffset(1000, 0, 50), 0, "no duration — the container did not declare one");
|
|
38
|
+
assert.equal(resumeByteOffset(1000, 100, 0), 0, "the viewer is at the beginning, where the edges already are");
|
|
39
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file WHICH THREAD answers a pull for subtitle cues.
|
|
3
|
+
*
|
|
4
|
+
* Field 2026-09-03, on `[HorribleSubs] Drifters - 04 [1080p].mkv`. The file had
|
|
5
|
+
* been downloaded in an earlier sitting, so the torrent worker's cluster walk
|
|
6
|
+
* found cues 1.5 s after the file was opened and pushed four batches — cursor 1
|
|
7
|
+
* to 31, covering everything up to 81.7 s — before the browser had subscribed.
|
|
8
|
+
* The browser's catch-up pull, which exists for exactly that case, answered a
|
|
9
|
+
* seven-byte `WEBVTT` with `x-subtitle-covered-clusters: 0` against 283
|
|
10
|
+
* indexed. So the viewer watched the first 82 s with no subtitles and the rest
|
|
11
|
+
* of the episode with them.
|
|
12
|
+
*
|
|
13
|
+
* The pull ran on the MAIN thread, where the torrent is a stand-in carrying
|
|
14
|
+
* `infoHash`, `name` and a `files` list — no `bitfield`, no `pieceLength`. The
|
|
15
|
+
* walk decides what it may read from those two, so every range read as "not
|
|
16
|
+
* downloaded", nothing was walked, and an empty document came back. An empty
|
|
17
|
+
* document is also the right answer for a file that holds no cues yet, which is
|
|
18
|
+
* why nothing reported a failure.
|
|
19
|
+
*
|
|
20
|
+
* These checks pin the two halves of the repair: the pull is addressed to the
|
|
21
|
+
* thread that owns the torrent, and a walk asked of a torrent that cannot say
|
|
22
|
+
* what it holds says so rather than answering emptily.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import test from "node:test";
|
|
26
|
+
import assert from "node:assert/strict";
|
|
27
|
+
|
|
28
|
+
import { SubtitleOrchestrator } from "../services/orchestrators/SubtitleOrchestrator.js";
|
|
29
|
+
import { cuesHeldFor } from "../services/torrent-worker/subtitle-cues.js";
|
|
30
|
+
|
|
31
|
+
/** The stand-in the main thread holds: a name, a file list, and no pieces. */
|
|
32
|
+
function mainThreadTorrent() {
|
|
33
|
+
return {
|
|
34
|
+
infoHash: "0".repeat(40),
|
|
35
|
+
name: "[HorribleSubs] Drifters - 04 [1080p].mkv",
|
|
36
|
+
sourceKey: "a".repeat(40),
|
|
37
|
+
files: [{ name: "[HorribleSubs] Drifters - 04 [1080p].mkv", length: 567_535_843, createReadStream: () => { throw new Error("not reached"); } }]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("a pull is answered by the thread that owns the torrent, not walked here", async () => {
|
|
42
|
+
const asked = [];
|
|
43
|
+
const pool = {
|
|
44
|
+
async getSubtitleCues(torrent, fileIndex, trackNumber) {
|
|
45
|
+
asked.push({ sourceKey: torrent.sourceKey, fileIndex, trackNumber });
|
|
46
|
+
return {
|
|
47
|
+
cues: [{ startSeconds: 5.4, endSeconds: 9.1, text: "So what if you brought them over?", seq: 1 }],
|
|
48
|
+
coveredClusters: 283,
|
|
49
|
+
indexedClusters: 283,
|
|
50
|
+
codecId: "S_TEXT/ASS",
|
|
51
|
+
codecPrivate: "",
|
|
52
|
+
language: ""
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const orchestrator = new SubtitleOrchestrator({ forget() {} });
|
|
58
|
+
const held = await orchestrator.getCues(pool, mainThreadTorrent(), 33, "a".repeat(40), 3);
|
|
59
|
+
|
|
60
|
+
assert.deepEqual(asked, [{ sourceKey: "a".repeat(40), fileIndex: 33, trackNumber: 3 }]);
|
|
61
|
+
assert.equal(held.cues.length, 1);
|
|
62
|
+
assert.equal(held.coveredClusters, 283, "the walk's own figure travels back, so the header cannot claim 0 of 283");
|
|
63
|
+
// The worker answers with the track's fields flat — only plain objects cross
|
|
64
|
+
// the boundary — and the caller reads them through `held.track`.
|
|
65
|
+
assert.equal(held.track.codecId, "S_TEXT/ASS");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("the cursor of a pulled cue is the found-order the worker assigned", async () => {
|
|
69
|
+
// The browser mixes cursors from pulls and pushes. Walking a second time on
|
|
70
|
+
// another thread would start a second `seq` counter and the two would not be
|
|
71
|
+
// comparable, which is the deeper reason the pull is not served locally.
|
|
72
|
+
const pool = {
|
|
73
|
+
async getSubtitleCues() {
|
|
74
|
+
return {
|
|
75
|
+
cues: [
|
|
76
|
+
{ startSeconds: 5.4, endSeconds: 9.1, text: "one", seq: 1 },
|
|
77
|
+
{ startSeconds: 81.7, endSeconds: 84.0, text: "two", seq: 31 }
|
|
78
|
+
],
|
|
79
|
+
coveredClusters: 12,
|
|
80
|
+
indexedClusters: 283,
|
|
81
|
+
codecId: "S_TEXT/ASS",
|
|
82
|
+
codecPrivate: "",
|
|
83
|
+
language: ""
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const orchestrator = new SubtitleOrchestrator({ forget() {} });
|
|
88
|
+
const held = await orchestrator.getCues(pool, mainThreadTorrent(), 33, "a".repeat(40), 3);
|
|
89
|
+
assert.deepEqual(held.cues.map((cue) => cue.seq), [1, 31]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a pool with no channel to the worker is refused, not answered emptily", async () => {
|
|
93
|
+
const orchestrator = new SubtitleOrchestrator({ forget() {} });
|
|
94
|
+
const held = await orchestrator.getCues({}, mainThreadTorrent(), 33, "a".repeat(40), 3);
|
|
95
|
+
assert.deepEqual(held, { cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("the walk refuses a torrent that cannot say which pieces it holds", async () => {
|
|
99
|
+
// Called directly, as the main thread used to call it. Without this guard the
|
|
100
|
+
// answer is an empty list indistinguishable from a file with no cues, which
|
|
101
|
+
// is what hid the defect for a whole session.
|
|
102
|
+
const held = await cuesHeldFor(mainThreadTorrent(), 33, "b".repeat(40), 3);
|
|
103
|
+
assert.deepEqual(held, { cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
|
|
104
|
+
});
|
|
@@ -60,7 +60,7 @@ test("the missing blocks are freed and asked of a second wire", () => {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
test("at most one duplicate per wire, however long the tail", () => {
|
|
63
|
-
const target = piece(512,
|
|
63
|
+
const target = piece(512, 16);
|
|
64
64
|
const torrent = {
|
|
65
65
|
pieces: [target],
|
|
66
66
|
wires: [wire(), wire()],
|
|
@@ -73,7 +73,29 @@ test("at most one duplicate per wire, however long the tail", () => {
|
|
|
73
73
|
assert.equal(target.cancelled.length, 2, "and no reservation is freed that nobody was asked for");
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
test("a
|
|
76
|
+
test("a piece still arriving normally is not duplicated at all", () => {
|
|
77
|
+
// Forty blocks outstanding is not a tail, it is a piece in transit. Asking
|
|
78
|
+
// for a second copy of it would spend the shared link on bytes that are
|
|
79
|
+
// already on their way — which is the whole difference between this and the
|
|
80
|
+
// 2-14 blocks a blocked reader was measured waiting on.
|
|
81
|
+
const target = piece(512, 40);
|
|
82
|
+
const torrent = {
|
|
83
|
+
pieces: [target],
|
|
84
|
+
wires: [wire(), wire()],
|
|
85
|
+
_request: () => true
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const result = duplicateTailFor(torrent, 0);
|
|
89
|
+
|
|
90
|
+
assert.equal(result.duplicated, 0);
|
|
91
|
+
assert.equal(target.cancelled.length, 0, "and nothing is freed either");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a wire whose pipeline is full does not end the attempt", () => {
|
|
95
|
+
// Pipelines are per wire, so one wire being full says nothing about the next.
|
|
96
|
+
// This used to stop the whole pass on the first refusal, on the stated
|
|
97
|
+
// reasoning that the remaining wires were "no emptier" — an assumption about
|
|
98
|
+
// other peers' queues that nothing measures.
|
|
77
99
|
const target = piece(512, 5);
|
|
78
100
|
const torrent = {
|
|
79
101
|
pieces: [target],
|
|
@@ -88,11 +110,33 @@ test("a wire whose pipeline is full ends the attempt", () => {
|
|
|
88
110
|
assert.equal(result.duplicated, 0);
|
|
89
111
|
assert.equal(
|
|
90
112
|
target.cancelled.length,
|
|
91
|
-
|
|
92
|
-
"
|
|
113
|
+
3,
|
|
114
|
+
"every wire was offered a block; a freed reservation nobody took is handed to whoever asks next"
|
|
93
115
|
);
|
|
94
116
|
});
|
|
95
117
|
|
|
118
|
+
test("a refusal by one wire does not cost the block a faster wire would have taken", () => {
|
|
119
|
+
const target = piece(512, 3);
|
|
120
|
+
const placed = [];
|
|
121
|
+
const torrent = {
|
|
122
|
+
pieces: [target],
|
|
123
|
+
wires: [wire({ speed: 900_000 }), wire({ speed: 800_000 })],
|
|
124
|
+
_request: (wireAsked, index) => {
|
|
125
|
+
// The first wire is full; the second is not.
|
|
126
|
+
if (wireAsked === torrent.wires[0]) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
placed.push(index);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const result = duplicateTailFor(torrent, 0);
|
|
135
|
+
|
|
136
|
+
assert.equal(result.duplicated, 1);
|
|
137
|
+
assert.deepEqual(placed, [0], "the second wire was still asked");
|
|
138
|
+
});
|
|
139
|
+
|
|
96
140
|
test("a piece with nothing missing is left alone", () => {
|
|
97
141
|
const target = piece(4, 0);
|
|
98
142
|
const torrent = { pieces: [target], wires: [wire()], _request: () => true };
|