@torrent-tv/proxy 2.89.6 → 2.89.8
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/AGENTS.md +32 -12
- package/CHANGELOG.md +2340 -2330
- package/assets/calibration/audio/NOTICE.md +19 -19
- package/assets/calibration/audio/manifest.json +98 -98
- package/biome.json +412 -412
- package/docs/browser-proxy-contract.md +155 -155
- package/docs/container-architecture.md +273 -273
- package/docs/encode-run-state.md +1 -1
- package/docs/playback-start-forecast.md +113 -98
- package/package.json +51 -50
- package/routes/api/sources/files/get.js +106 -106
- package/scripts/build-audio-samples.mjs +31 -31
- package/services/encode/EncodeRun.js +67 -15
- package/services/encode/EncodeRuns.js +19 -7
- package/services/encode/OutputOpening.js +784 -784
- package/services/encode/audio-calibration.js +67 -67
- package/services/encode/audio-work-rate.js +15 -15
- package/services/encode/encode-exit.js +2 -0
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/output/index.js +24 -24
- package/services/encode/piece-completeness.js +60 -2
- package/services/encode/run-command.js +757 -755
- package/services/encode/segment-formats/index.js +1 -0
- package/services/encode/segment-formats/mp4-boxes.js +8 -1
- package/services/media/ContainerOrchestrator.js +202 -202
- package/services/media/KeyframeTables.js +356 -356
- package/services/media/SubtitleCues.js +592 -592
- package/services/media/audio-inventory.js +299 -299
- package/services/media/container/AviContainer.js +268 -268
- package/services/media/container/Container.js +632 -632
- package/services/media/container/ContainerFactory.js +38 -38
- package/services/media/container/MatroskaContainer.js +1031 -1031
- package/services/media/container/Mp4Container.js +1354 -1354
- package/services/media/container/SubtitleFileContainer.js +339 -339
- package/services/media/container/ebml-stream.js +174 -174
- package/services/media/container/matroska-clusters.js +1165 -1165
- package/services/media/container/unavailable.js +77 -77
- package/services/media/tracks/ContainerTrack.js +84 -84
- package/services/media/tracks/TextSubtitleTrack.js +295 -295
- package/services/storage/piece-store/shared-piece-store.js +1937 -1937
- package/services/storage/segment-store/SegmentStore.js +1294 -1294
- package/services/torrent/Contents.js +382 -382
- package/services/torrent/episode-naming.js +182 -182
- package/services/torrent/torrent-pool.js +2688 -2688
- package/services/torrent/worker/client.js +836 -836
- package/services/torrent/worker/held-bytes.js +231 -231
- package/services/torrent/worker/worker.js +1137 -1137
- package/services/transport/data-channel-handler.js +1373 -1373
- package/services/viewer/PriorityMap.js +421 -421
- package/test/audio-calibration.test.js +23 -23
- package/test/auto-quality-step.test.js +762 -762
- package/test/coalesce.test.js +39 -39
- package/test/coverage-follows-the-disk.test.js +5 -0
- package/test/decode-cost.test.js +475 -475
- package/test/encode-run.test.js +58 -0
- package/test/episode-naming.test.js +131 -131
- package/test/failed-encoder-still-serves-what-exists.test.js +1 -1
- package/test/fixtures/audio-frame-boundary.json +1 -1
- package/test/fixtures/refused-audio-20261002.json +10 -0
- package/test/fmp4-presentation-clock.test.js +4 -2
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +11 -13
- package/test/held-read-ownership.test.js +77 -77
- package/test/helpers/encode-run.js +14 -6
- package/test/helpers/held-file.js +77 -77
- package/test/helpers/manager.js +62 -62
- package/test/helpers/matroska-file.js +296 -296
- package/test/keyframe-table-budget.test.js +123 -123
- package/test/matroska-cluster-discovery.test.js +323 -323
- package/test/one-output-one-session.test.js +273 -273
- package/test/output-speed.test.js +6 -1
- package/test/piece-completeness.test.js +37 -1
- package/test/quality-variants.test.js +1274 -1270
- package/test/rate-control-in-the-output.test.js +4 -3
- package/test/reads-not-yet-arrived.test.js +218 -218
- package/test/seek-frees-the-viewers-requests.test.js +1 -1
- package/test/segment-closed-on-both-branches.test.js +114 -114
- package/test/segment-judged-by-its-own-header.test.js +222 -222
- package/test/segment-serve-wiring.test.js +481 -481
- package/test/serving-existing-output.test.js +111 -111
- package/test/source-files-route.test.js +113 -113
- package/test/subtitle-cue-walk.test.js +385 -385
- package/test/subtitle-metadata.test.js +15 -15
- package/test/subtitle-plan-waits.test.js +125 -125
- package/test/subtitle-track-numbering.test.js +391 -391
- package/test/two-viewers-one-picture.test.js +508 -508
- package/test/viewer-requests-admission.test.js +92 -92
- package/test/viewer-starts-on-map.test.js +27 -27
- package/test/viewer-states.test.js +5 -2
- package/test/viewing-generations.test.js +390 -390
- package/test/whole-file-held-read.test.js +30 -30
- package/utils/coalesce.js +41 -41
|
@@ -1,231 +1,231 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file What of a file the torrent already HOLDS, and reading it without
|
|
3
|
-
* asking the swarm for anything.
|
|
4
|
-
*
|
|
5
|
-
* Two questions, and both are the torrent's own: which pieces have arrived is
|
|
6
|
-
* its bitfield, and reading a piece it has is its store. They are here because
|
|
7
|
-
* of that and for no other reason — everything else that used to keep them
|
|
8
|
-
* company, the subtitle plan and the cue walk and the cursor a browser follows,
|
|
9
|
-
* is what a FILE says about itself and moved to the thread the sessions are on
|
|
10
|
-
* (2026-09-15).
|
|
11
|
-
*
|
|
12
|
-
* **Why the subtitle walk cannot use the ordinary range read.** That one
|
|
13
|
-
* declares demand: it registers a window, steers the swarm toward the piece it
|
|
14
|
-
* is stopped on, and waits. Right for a viewer waiting on a segment, wrong
|
|
15
|
-
* here — switching subtitles on must never pull bytes the viewer is not waiting
|
|
16
|
-
* for, which is the rule the walk has kept since it was written. So these two
|
|
17
|
-
* read the store and say "not here" rather than fetching.
|
|
18
|
-
*
|
|
19
|
-
* **The held ranges are answered as a LIST, not asked piece by piece.** The
|
|
20
|
-
* walk asks "is this cluster downloaded" for every cluster of the file — on the
|
|
21
|
-
* field files that is hundreds of questions per pass, and a pass runs every
|
|
22
|
-
* three seconds. One list per pass costs one message; the same answers asked
|
|
23
|
-
* one at a time cost hundreds of round trips for a walk that is meant to be
|
|
24
|
-
* free when there is nothing new to find.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The byte ranges of one file that are downloaded WHOLE.
|
|
29
|
-
*
|
|
30
|
-
* Whole because a piece is the unit the swarm delivers and the unit the
|
|
31
|
-
* bitfield counts: a range that is half a piece short cannot be read, so
|
|
32
|
-
* reporting it would be reporting bytes that are not there.
|
|
33
|
-
*
|
|
34
|
-
* @param {object} torrent
|
|
35
|
-
* @param {number} fileIndex
|
|
36
|
-
* @returns {Array<[number, number]>} Ascending, non-overlapping, inclusive
|
|
37
|
-
* offsets WITHIN the file. Empty when nothing of it has arrived, or when the
|
|
38
|
-
* torrent cannot say — which reads the same way: nothing may be walked yet.
|
|
39
|
-
*/
|
|
40
|
-
export function heldRangesOf(torrent, fileIndex) {
|
|
41
|
-
const file = torrent?.files?.[fileIndex];
|
|
42
|
-
const pieceLength = Number(torrent?.pieceLength);
|
|
43
|
-
if (!file || !Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
|
|
44
|
-
return [];
|
|
45
|
-
}
|
|
46
|
-
const offset = Number(file.offset) || 0;
|
|
47
|
-
const length = Number(file.length) || 0;
|
|
48
|
-
if (!(length > 0)) {
|
|
49
|
-
return [];
|
|
50
|
-
}
|
|
51
|
-
const first = Math.floor(offset / pieceLength);
|
|
52
|
-
const last = Math.floor((offset + length - 1) / pieceLength);
|
|
53
|
-
/** @type {Array<[number, number]>} */
|
|
54
|
-
const ranges = [];
|
|
55
|
-
let runStart = -1;
|
|
56
|
-
for (let index = first; index <= last; index += 1) {
|
|
57
|
-
if (torrent.bitfield.get(index)) {
|
|
58
|
-
if (runStart < 0) {
|
|
59
|
-
runStart = index;
|
|
60
|
-
}
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (runStart >= 0) {
|
|
64
|
-
ranges.push(rangeWithin(runStart, index - 1, pieceLength, offset, length));
|
|
65
|
-
runStart = -1;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
if (runStart >= 0) {
|
|
69
|
-
ranges.push(rangeWithin(runStart, last, pieceLength, offset, length));
|
|
70
|
-
}
|
|
71
|
-
return ranges;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* A run of whole pieces, as offsets within the file.
|
|
76
|
-
*
|
|
77
|
-
* @param {number} firstPiece
|
|
78
|
-
* @param {number} lastPiece
|
|
79
|
-
* @param {number} pieceLength
|
|
80
|
-
* @param {number} offset - Where the file begins in the torrent.
|
|
81
|
-
* @param {number} length - How long the file is.
|
|
82
|
-
* @returns {[number, number]}
|
|
83
|
-
*/
|
|
84
|
-
function rangeWithin(firstPiece, lastPiece, pieceLength, offset, length) {
|
|
85
|
-
const start = Math.max(0, firstPiece * pieceLength - offset);
|
|
86
|
-
const end = Math.min(length - 1, (lastPiece + 1) * pieceLength - 1 - offset);
|
|
87
|
-
return [start, end];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Whether every piece under an inclusive range of a file has arrived.
|
|
92
|
-
*
|
|
93
|
-
* @param {object} torrent
|
|
94
|
-
* @param {number} fileIndex
|
|
95
|
-
* @param {number} start
|
|
96
|
-
* @param {number} end - Inclusive.
|
|
97
|
-
* @returns {boolean}
|
|
98
|
-
*/
|
|
99
|
-
export function isRangeHeld(torrent, fileIndex, start, end) {
|
|
100
|
-
const file = torrent?.files?.[fileIndex];
|
|
101
|
-
const pieceLength = Number(torrent?.pieceLength);
|
|
102
|
-
if (!file || !Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
const offset = Number(file.offset) || 0;
|
|
106
|
-
const first = Math.floor((offset + start) / pieceLength);
|
|
107
|
-
const last = Math.floor((offset + end) / pieceLength);
|
|
108
|
-
for (let index = first; index <= last; index += 1) {
|
|
109
|
-
if (!torrent.bitfield.get(index)) {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* How long a read of bytes the torrent HOLDS may take before it is taken to be
|
|
118
|
-
* a fault.
|
|
119
|
-
*
|
|
120
|
-
* Not a measurement and nothing is derived from it: the bytes are checked to be
|
|
121
|
-
* held before the read starts, so a read that does not finish is a store that
|
|
122
|
-
* failed to serve what it has, and the line it writes says so. Until 2026-10-01
|
|
123
|
-
* this read was started on bytes that had NOT arrived, the stream waited for
|
|
124
|
-
* them, and this was the bound that gave it up — 36 such reads gave up at once
|
|
125
|
-
* at a torrent's open, and one of them was the Cues table of the file being
|
|
126
|
-
* watched.
|
|
127
|
-
*/
|
|
128
|
-
const READ_ABANDON_MS = 30_000;
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Read a byte range of a file straight from the store, without asking the swarm
|
|
132
|
-
* for anything.
|
|
133
|
-
*
|
|
134
|
-
* Answered at once with null where any piece under the range has not arrived:
|
|
135
|
-
* "not here" is the whole answer, and nothing is waited for or requested.
|
|
136
|
-
*
|
|
137
|
-
* The bytes come back in a buffer that owns its whole memory — not a slice of
|
|
138
|
-
* Node's shared pool and not a view of the store's own blocks — so the reply can
|
|
139
|
-
* hand that memory to the other thread instead of copying it, and nothing the
|
|
140
|
-
* store or another read still uses goes with it.
|
|
141
|
-
*
|
|
142
|
-
* @param {object} torrent
|
|
143
|
-
* @param {number} fileIndex
|
|
144
|
-
* @param {number} start
|
|
145
|
-
* @param {number} end - Inclusive.
|
|
146
|
-
* @param {{ info: Function }} [logger]
|
|
147
|
-
* @returns {Promise<Buffer | null>}
|
|
148
|
-
*/
|
|
149
|
-
export function readHeldBytes(torrent, fileIndex, start, end, logger = null) {
|
|
150
|
-
const file = torrent?.files?.[fileIndex];
|
|
151
|
-
if (!file || !(end >= start) || !(start >= 0)) {
|
|
152
|
-
return Promise.resolve(null);
|
|
153
|
-
}
|
|
154
|
-
const last = Math.min(end, Number(file.length) - 1);
|
|
155
|
-
if (!isRangeHeld(torrent, fileIndex, start, last)) {
|
|
156
|
-
return Promise.resolve(null);
|
|
157
|
-
}
|
|
158
|
-
return new Promise((resolve) => {
|
|
159
|
-
const chunks = [];
|
|
160
|
-
let stream;
|
|
161
|
-
try {
|
|
162
|
-
stream = file.createReadStream({ start, end: last });
|
|
163
|
-
} catch {
|
|
164
|
-
resolve(null);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
let settled = false;
|
|
168
|
-
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
169
|
-
let abandon = null;
|
|
170
|
-
const settle = (value) => {
|
|
171
|
-
if (settled) {
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
settled = true;
|
|
175
|
-
if (abandon !== null) {
|
|
176
|
-
clearTimeout(abandon);
|
|
177
|
-
}
|
|
178
|
-
if (value === null) {
|
|
179
|
-
stream.destroy?.();
|
|
180
|
-
}
|
|
181
|
-
resolve(value);
|
|
182
|
-
};
|
|
183
|
-
abandon = setTimeout(() => {
|
|
184
|
-
logger?.info(
|
|
185
|
-
`subtitles: a read of ${start}-${last} in "${String(file.name).slice(0, 40)}" ` +
|
|
186
|
-
`was held but not served in ${READ_ABANDON_MS / 1000}s and was given up — the store failed to serve bytes it has`
|
|
187
|
-
);
|
|
188
|
-
settle(null);
|
|
189
|
-
}, READ_ABANDON_MS);
|
|
190
|
-
abandon.unref?.();
|
|
191
|
-
stream.on("data", (chunk) => chunks.push(chunk));
|
|
192
|
-
stream.on("end", () => settle(ownedCopyOf(chunks)));
|
|
193
|
-
stream.on("error", () => settle(null));
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* The chunks as one buffer over memory of its own.
|
|
199
|
-
*
|
|
200
|
-
* @param {Uint8Array[]} chunks
|
|
201
|
-
* @returns {Buffer}
|
|
202
|
-
*/
|
|
203
|
-
function ownedCopyOf(chunks) {
|
|
204
|
-
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
205
|
-
// `allocUnsafeSlow` never takes from the shared pool, so the buffer is the
|
|
206
|
-
// whole of its ArrayBuffer and may be transferred.
|
|
207
|
-
const owned = Buffer.allocUnsafeSlow(total);
|
|
208
|
-
let at = 0;
|
|
209
|
-
for (const chunk of chunks) {
|
|
210
|
-
owned.set(chunk, at);
|
|
211
|
-
at += chunk.length;
|
|
212
|
-
}
|
|
213
|
-
return owned;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Whether a buffer may be TRANSFERRED to another thread: it must be the whole of
|
|
218
|
-
* a plain ArrayBuffer. A slice of the shared pool would take every other
|
|
219
|
-
* buffer in the pool with it, and shared memory cannot be transferred at all.
|
|
220
|
-
*
|
|
221
|
-
* @param {unknown} bytes
|
|
222
|
-
* @returns {boolean}
|
|
223
|
-
*/
|
|
224
|
-
export function ownsItsMemory(bytes) {
|
|
225
|
-
return (
|
|
226
|
-
bytes instanceof Uint8Array &&
|
|
227
|
-
bytes.buffer instanceof ArrayBuffer &&
|
|
228
|
-
bytes.byteOffset === 0 &&
|
|
229
|
-
bytes.byteLength === bytes.buffer.byteLength
|
|
230
|
-
);
|
|
231
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file What of a file the torrent already HOLDS, and reading it without
|
|
3
|
+
* asking the swarm for anything.
|
|
4
|
+
*
|
|
5
|
+
* Two questions, and both are the torrent's own: which pieces have arrived is
|
|
6
|
+
* its bitfield, and reading a piece it has is its store. They are here because
|
|
7
|
+
* of that and for no other reason — everything else that used to keep them
|
|
8
|
+
* company, the subtitle plan and the cue walk and the cursor a browser follows,
|
|
9
|
+
* is what a FILE says about itself and moved to the thread the sessions are on
|
|
10
|
+
* (2026-09-15).
|
|
11
|
+
*
|
|
12
|
+
* **Why the subtitle walk cannot use the ordinary range read.** That one
|
|
13
|
+
* declares demand: it registers a window, steers the swarm toward the piece it
|
|
14
|
+
* is stopped on, and waits. Right for a viewer waiting on a segment, wrong
|
|
15
|
+
* here — switching subtitles on must never pull bytes the viewer is not waiting
|
|
16
|
+
* for, which is the rule the walk has kept since it was written. So these two
|
|
17
|
+
* read the store and say "not here" rather than fetching.
|
|
18
|
+
*
|
|
19
|
+
* **The held ranges are answered as a LIST, not asked piece by piece.** The
|
|
20
|
+
* walk asks "is this cluster downloaded" for every cluster of the file — on the
|
|
21
|
+
* field files that is hundreds of questions per pass, and a pass runs every
|
|
22
|
+
* three seconds. One list per pass costs one message; the same answers asked
|
|
23
|
+
* one at a time cost hundreds of round trips for a walk that is meant to be
|
|
24
|
+
* free when there is nothing new to find.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The byte ranges of one file that are downloaded WHOLE.
|
|
29
|
+
*
|
|
30
|
+
* Whole because a piece is the unit the swarm delivers and the unit the
|
|
31
|
+
* bitfield counts: a range that is half a piece short cannot be read, so
|
|
32
|
+
* reporting it would be reporting bytes that are not there.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} torrent
|
|
35
|
+
* @param {number} fileIndex
|
|
36
|
+
* @returns {Array<[number, number]>} Ascending, non-overlapping, inclusive
|
|
37
|
+
* offsets WITHIN the file. Empty when nothing of it has arrived, or when the
|
|
38
|
+
* torrent cannot say — which reads the same way: nothing may be walked yet.
|
|
39
|
+
*/
|
|
40
|
+
export function heldRangesOf(torrent, fileIndex) {
|
|
41
|
+
const file = torrent?.files?.[fileIndex];
|
|
42
|
+
const pieceLength = Number(torrent?.pieceLength);
|
|
43
|
+
if (!file || !Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const offset = Number(file.offset) || 0;
|
|
47
|
+
const length = Number(file.length) || 0;
|
|
48
|
+
if (!(length > 0)) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const first = Math.floor(offset / pieceLength);
|
|
52
|
+
const last = Math.floor((offset + length - 1) / pieceLength);
|
|
53
|
+
/** @type {Array<[number, number]>} */
|
|
54
|
+
const ranges = [];
|
|
55
|
+
let runStart = -1;
|
|
56
|
+
for (let index = first; index <= last; index += 1) {
|
|
57
|
+
if (torrent.bitfield.get(index)) {
|
|
58
|
+
if (runStart < 0) {
|
|
59
|
+
runStart = index;
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (runStart >= 0) {
|
|
64
|
+
ranges.push(rangeWithin(runStart, index - 1, pieceLength, offset, length));
|
|
65
|
+
runStart = -1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (runStart >= 0) {
|
|
69
|
+
ranges.push(rangeWithin(runStart, last, pieceLength, offset, length));
|
|
70
|
+
}
|
|
71
|
+
return ranges;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A run of whole pieces, as offsets within the file.
|
|
76
|
+
*
|
|
77
|
+
* @param {number} firstPiece
|
|
78
|
+
* @param {number} lastPiece
|
|
79
|
+
* @param {number} pieceLength
|
|
80
|
+
* @param {number} offset - Where the file begins in the torrent.
|
|
81
|
+
* @param {number} length - How long the file is.
|
|
82
|
+
* @returns {[number, number]}
|
|
83
|
+
*/
|
|
84
|
+
function rangeWithin(firstPiece, lastPiece, pieceLength, offset, length) {
|
|
85
|
+
const start = Math.max(0, firstPiece * pieceLength - offset);
|
|
86
|
+
const end = Math.min(length - 1, (lastPiece + 1) * pieceLength - 1 - offset);
|
|
87
|
+
return [start, end];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether every piece under an inclusive range of a file has arrived.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} torrent
|
|
94
|
+
* @param {number} fileIndex
|
|
95
|
+
* @param {number} start
|
|
96
|
+
* @param {number} end - Inclusive.
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
export function isRangeHeld(torrent, fileIndex, start, end) {
|
|
100
|
+
const file = torrent?.files?.[fileIndex];
|
|
101
|
+
const pieceLength = Number(torrent?.pieceLength);
|
|
102
|
+
if (!file || !Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const offset = Number(file.offset) || 0;
|
|
106
|
+
const first = Math.floor((offset + start) / pieceLength);
|
|
107
|
+
const last = Math.floor((offset + end) / pieceLength);
|
|
108
|
+
for (let index = first; index <= last; index += 1) {
|
|
109
|
+
if (!torrent.bitfield.get(index)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* How long a read of bytes the torrent HOLDS may take before it is taken to be
|
|
118
|
+
* a fault.
|
|
119
|
+
*
|
|
120
|
+
* Not a measurement and nothing is derived from it: the bytes are checked to be
|
|
121
|
+
* held before the read starts, so a read that does not finish is a store that
|
|
122
|
+
* failed to serve what it has, and the line it writes says so. Until 2026-10-01
|
|
123
|
+
* this read was started on bytes that had NOT arrived, the stream waited for
|
|
124
|
+
* them, and this was the bound that gave it up — 36 such reads gave up at once
|
|
125
|
+
* at a torrent's open, and one of them was the Cues table of the file being
|
|
126
|
+
* watched.
|
|
127
|
+
*/
|
|
128
|
+
const READ_ABANDON_MS = 30_000;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Read a byte range of a file straight from the store, without asking the swarm
|
|
132
|
+
* for anything.
|
|
133
|
+
*
|
|
134
|
+
* Answered at once with null where any piece under the range has not arrived:
|
|
135
|
+
* "not here" is the whole answer, and nothing is waited for or requested.
|
|
136
|
+
*
|
|
137
|
+
* The bytes come back in a buffer that owns its whole memory — not a slice of
|
|
138
|
+
* Node's shared pool and not a view of the store's own blocks — so the reply can
|
|
139
|
+
* hand that memory to the other thread instead of copying it, and nothing the
|
|
140
|
+
* store or another read still uses goes with it.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} torrent
|
|
143
|
+
* @param {number} fileIndex
|
|
144
|
+
* @param {number} start
|
|
145
|
+
* @param {number} end - Inclusive.
|
|
146
|
+
* @param {{ info: Function }} [logger]
|
|
147
|
+
* @returns {Promise<Buffer | null>}
|
|
148
|
+
*/
|
|
149
|
+
export function readHeldBytes(torrent, fileIndex, start, end, logger = null) {
|
|
150
|
+
const file = torrent?.files?.[fileIndex];
|
|
151
|
+
if (!file || !(end >= start) || !(start >= 0)) {
|
|
152
|
+
return Promise.resolve(null);
|
|
153
|
+
}
|
|
154
|
+
const last = Math.min(end, Number(file.length) - 1);
|
|
155
|
+
if (!isRangeHeld(torrent, fileIndex, start, last)) {
|
|
156
|
+
return Promise.resolve(null);
|
|
157
|
+
}
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let stream;
|
|
161
|
+
try {
|
|
162
|
+
stream = file.createReadStream({ start, end: last });
|
|
163
|
+
} catch {
|
|
164
|
+
resolve(null);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let settled = false;
|
|
168
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
169
|
+
let abandon = null;
|
|
170
|
+
const settle = (value) => {
|
|
171
|
+
if (settled) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
settled = true;
|
|
175
|
+
if (abandon !== null) {
|
|
176
|
+
clearTimeout(abandon);
|
|
177
|
+
}
|
|
178
|
+
if (value === null) {
|
|
179
|
+
stream.destroy?.();
|
|
180
|
+
}
|
|
181
|
+
resolve(value);
|
|
182
|
+
};
|
|
183
|
+
abandon = setTimeout(() => {
|
|
184
|
+
logger?.info(
|
|
185
|
+
`subtitles: a read of ${start}-${last} in "${String(file.name).slice(0, 40)}" ` +
|
|
186
|
+
`was held but not served in ${READ_ABANDON_MS / 1000}s and was given up — the store failed to serve bytes it has`
|
|
187
|
+
);
|
|
188
|
+
settle(null);
|
|
189
|
+
}, READ_ABANDON_MS);
|
|
190
|
+
abandon.unref?.();
|
|
191
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
192
|
+
stream.on("end", () => settle(ownedCopyOf(chunks)));
|
|
193
|
+
stream.on("error", () => settle(null));
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The chunks as one buffer over memory of its own.
|
|
199
|
+
*
|
|
200
|
+
* @param {Uint8Array[]} chunks
|
|
201
|
+
* @returns {Buffer}
|
|
202
|
+
*/
|
|
203
|
+
function ownedCopyOf(chunks) {
|
|
204
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
205
|
+
// `allocUnsafeSlow` never takes from the shared pool, so the buffer is the
|
|
206
|
+
// whole of its ArrayBuffer and may be transferred.
|
|
207
|
+
const owned = Buffer.allocUnsafeSlow(total);
|
|
208
|
+
let at = 0;
|
|
209
|
+
for (const chunk of chunks) {
|
|
210
|
+
owned.set(chunk, at);
|
|
211
|
+
at += chunk.length;
|
|
212
|
+
}
|
|
213
|
+
return owned;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether a buffer may be TRANSFERRED to another thread: it must be the whole of
|
|
218
|
+
* a plain ArrayBuffer. A slice of the shared pool would take every other
|
|
219
|
+
* buffer in the pool with it, and shared memory cannot be transferred at all.
|
|
220
|
+
*
|
|
221
|
+
* @param {unknown} bytes
|
|
222
|
+
* @returns {boolean}
|
|
223
|
+
*/
|
|
224
|
+
export function ownsItsMemory(bytes) {
|
|
225
|
+
return (
|
|
226
|
+
bytes instanceof Uint8Array &&
|
|
227
|
+
bytes.buffer instanceof ArrayBuffer &&
|
|
228
|
+
bytes.byteOffset === 0 &&
|
|
229
|
+
bytes.byteLength === bytes.buffer.byteLength
|
|
230
|
+
);
|
|
231
|
+
}
|