@torrent-tv/proxy 2.88.6 → 2.89.0
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 +21 -12
- package/CHANGELOG.md +17 -3
- package/assets/calibration/audio/NOTICE.md +19 -19
- package/assets/calibration/audio/manifest.json +98 -98
- package/docs/container-architecture.md +77 -22
- package/docs/playback-start-forecast.md +56 -56
- package/main2.cpuprofile +1 -0
- package/package.json +1 -1
- package/scripts/build-audio-samples.mjs +31 -31
- package/server.js +42 -27
- package/services/encode/OutputOpening.js +5 -4
- package/services/encode/audio-calibration.js +67 -67
- package/services/encode/audio-work-rate.js +15 -15
- package/services/media/ContainerOrchestrator.js +99 -46
- package/services/media/KeyframeTables.js +61 -21
- package/services/media/SubtitleCues.js +255 -183
- package/services/media/container/AviContainer.js +5 -3
- package/services/media/container/Container.js +66 -43
- package/services/media/container/ContainerFactory.js +8 -26
- package/services/media/container/MatroskaContainer.js +719 -1182
- package/services/media/container/Mp4Container.js +83 -73
- package/services/media/container/ebml-stream.js +174 -0
- package/services/media/container/matroska-clusters.js +1165 -0
- package/services/media/container/unavailable.js +77 -0
- package/services/media/tracks/ContainerTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +9 -1
- package/services/server/playback-readiness.js +516 -516
- package/services/torrent/worker/client.js +6 -2
- package/services/torrent/worker/held-bytes.js +84 -8
- package/services/torrent/worker/worker.js +10 -2
- package/services/transport/data-channel-handler.js +7 -3
- package/services/viewer/PriorityMap.js +31 -0
- package/test/audio-calibration.test.js +23 -23
- package/test/coalesce.test.js +39 -0
- package/test/held-read-ownership.test.js +77 -0
- package/test/helpers/held-file.js +50 -2
- package/test/helpers/matroska-file.js +296 -0
- package/test/keyframe-table-budget.test.js +21 -0
- package/test/matroska-cluster-discovery.test.js +323 -0
- package/test/reads-not-yet-arrived.test.js +218 -0
- package/test/subtitle-cue-walk.test.js +16 -1
- package/test/subtitle-plan-waits.test.js +125 -0
- package/test/subtitle-track-numbering.test.js +8 -3
- package/test/viewer-starts-on-map.test.js +27 -0
- package/utils/coalesce.js +41 -0
|
@@ -375,7 +375,10 @@ export class TorrentWorkerClient {
|
|
|
375
375
|
*/
|
|
376
376
|
async readHeld({ sourceKey, fileIndex, start, end }) {
|
|
377
377
|
const answer = await this.#caller.call(Command.READ_HELD, { sourceKey, fileIndex, start, end });
|
|
378
|
-
|
|
378
|
+
const bytes = answer?.bytes;
|
|
379
|
+
// The memory was handed over by the worker, so it is this thread's alone:
|
|
380
|
+
// viewed, not copied a second time.
|
|
381
|
+
return bytes ? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) : null;
|
|
379
382
|
}
|
|
380
383
|
|
|
381
384
|
async warmResumePosition({ sourceKey, fileIndex, positionSeconds, durationSeconds }) {
|
|
@@ -601,7 +604,7 @@ export class TorrentWorkerClient {
|
|
|
601
604
|
* not reachable from here.
|
|
602
605
|
*
|
|
603
606
|
* @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
|
|
604
|
-
* @returns {Promise<{ infoHash: string, name: string, sourceKey: string, files: object[] }>}
|
|
607
|
+
* @returns {Promise<{ infoHash: string, name: string, pieceLength: number, sourceKey: string, files: object[] }>}
|
|
605
608
|
*/
|
|
606
609
|
async getTorrent({ sourceKey, sourceType, source }) {
|
|
607
610
|
const info = await this.addSource({ sourceKey, sourceType, source });
|
|
@@ -609,6 +612,7 @@ export class TorrentWorkerClient {
|
|
|
609
612
|
return {
|
|
610
613
|
infoHash: info.infoHash,
|
|
611
614
|
name: info.name,
|
|
615
|
+
pieceLength: Number(info.pieceLength) || 0,
|
|
612
616
|
// Carried so helpers that receive only the torrent can still name it to
|
|
613
617
|
// the worker.
|
|
614
618
|
sourceKey,
|
|
@@ -88,13 +88,42 @@ function rangeWithin(firstPiece, lastPiece, pieceLength, offset, length) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
92
|
-
* given up.
|
|
91
|
+
* Whether every piece under an inclusive range of a file has arrived.
|
|
93
92
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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.
|
|
98
127
|
*/
|
|
99
128
|
const READ_ABANDON_MS = 30_000;
|
|
100
129
|
|
|
@@ -102,6 +131,14 @@ const READ_ABANDON_MS = 30_000;
|
|
|
102
131
|
* Read a byte range of a file straight from the store, without asking the swarm
|
|
103
132
|
* for anything.
|
|
104
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
|
+
*
|
|
105
142
|
* @param {object} torrent
|
|
106
143
|
* @param {number} fileIndex
|
|
107
144
|
* @param {number} start
|
|
@@ -115,6 +152,9 @@ export function readHeldBytes(torrent, fileIndex, start, end, logger = null) {
|
|
|
115
152
|
return Promise.resolve(null);
|
|
116
153
|
}
|
|
117
154
|
const last = Math.min(end, Number(file.length) - 1);
|
|
155
|
+
if (!isRangeHeld(torrent, fileIndex, start, last)) {
|
|
156
|
+
return Promise.resolve(null);
|
|
157
|
+
}
|
|
118
158
|
return new Promise((resolve) => {
|
|
119
159
|
const chunks = [];
|
|
120
160
|
let stream;
|
|
@@ -143,13 +183,49 @@ export function readHeldBytes(torrent, fileIndex, start, end, logger = null) {
|
|
|
143
183
|
abandon = setTimeout(() => {
|
|
144
184
|
logger?.info(
|
|
145
185
|
`subtitles: a read of ${start}-${last} in "${String(file.name).slice(0, 40)}" ` +
|
|
146
|
-
`
|
|
186
|
+
`was held but not served in ${READ_ABANDON_MS / 1000}s and was given up — the store failed to serve bytes it has`
|
|
147
187
|
);
|
|
148
188
|
settle(null);
|
|
149
189
|
}, READ_ABANDON_MS);
|
|
150
190
|
abandon.unref?.();
|
|
151
191
|
stream.on("data", (chunk) => chunks.push(chunk));
|
|
152
|
-
stream.on("end", () => settle(
|
|
192
|
+
stream.on("end", () => settle(ownedCopyOf(chunks)));
|
|
153
193
|
stream.on("error", () => settle(null));
|
|
154
194
|
});
|
|
155
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
|
+
}
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "./resume-warm.js";
|
|
31
31
|
import { fillFileInBackground } from "./background-fill.js";
|
|
32
32
|
import { demandFor } from "../download/registry.js";
|
|
33
|
-
import { heldRangesOf, readHeldBytes } from "./held-bytes.js";
|
|
33
|
+
import { heldRangesOf, ownsItsMemory, readHeldBytes } from "./held-bytes.js";
|
|
34
34
|
import { CompletedFiles, completedFilesRoot } from "../../storage/files/CompletedFiles.js";
|
|
35
35
|
import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../../storage/files/piece-from-whole-file.js";
|
|
36
36
|
import { Command, Event } from "./protocol.js";
|
|
@@ -343,6 +343,10 @@ async function runCommand(command, params, id) {
|
|
|
343
343
|
return {
|
|
344
344
|
infoHash: torrent.infoHash,
|
|
345
345
|
name: torrent.name,
|
|
346
|
+
// The unit the swarm delivers and the store keeps. Said to the main
|
|
347
|
+
// thread as a number so a reading there can size its portions to it
|
|
348
|
+
// without knowing what a piece is.
|
|
349
|
+
pieceLength: Number(torrent.pieceLength) || 0,
|
|
346
350
|
// Files cross as plain data; the objects stay here.
|
|
347
351
|
files: (torrent.files ?? []).map((file, index) => ({
|
|
348
352
|
index,
|
|
@@ -567,7 +571,11 @@ parentPort.on("message", async (message) => {
|
|
|
567
571
|
const { command, id, params } = message ?? {};
|
|
568
572
|
try {
|
|
569
573
|
const result = await runCommand(command, params ?? {}, id);
|
|
570
|
-
|
|
574
|
+
// A held read's bytes are handed over rather than copied, and only when
|
|
575
|
+
// the buffer is the whole of its own memory (`ownsItsMemory`): anything
|
|
576
|
+
// else would take memory the store or another read still uses with it.
|
|
577
|
+
const transfer = command === Command.READ_HELD && ownsItsMemory(result?.bytes) ? [result.bytes.buffer] : [];
|
|
578
|
+
parentPort.postMessage({ type: Event.RESULT, id, result }, transfer);
|
|
571
579
|
} catch (error) {
|
|
572
580
|
parentPort.postMessage({ type: Event.ERROR, id, error: error?.message ?? String(error) });
|
|
573
581
|
}
|
|
@@ -772,12 +772,16 @@ export function createDataChannelHandler({
|
|
|
772
772
|
* are tiny (kilobytes at most for a whole track), so this is one message,
|
|
773
773
|
* not a stream.
|
|
774
774
|
*
|
|
775
|
+
* `withdrawn` names, by their found-order numbers, cues sent earlier that the
|
|
776
|
+
* proxy has taken back — read from a stretch that turned out not to be a
|
|
777
|
+
* cluster. Each cue carries its own number (`seq`) for that purpose.
|
|
778
|
+
*
|
|
775
779
|
* @param {{ sourceKey: string, fileIndex: number, trackIndex: number, cues: object[],
|
|
776
|
-
* language: string, detectedLanguage: { code: string, name: string } | null,
|
|
780
|
+
* withdrawn?: number[], language: string, detectedLanguage: { code: string, name: string } | null,
|
|
777
781
|
* cursor: number }} event
|
|
778
782
|
* @returns {void}
|
|
779
783
|
*/
|
|
780
|
-
function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, language, detectedLanguage, cursor }) {
|
|
784
|
+
function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, withdrawn = [], language, detectedLanguage, cursor }) {
|
|
781
785
|
const names = viewersWantingCues(sourceKey, fileIndex);
|
|
782
786
|
if (names.length === 0) {
|
|
783
787
|
log(
|
|
@@ -787,7 +791,7 @@ export function createDataChannelHandler({
|
|
|
787
791
|
return;
|
|
788
792
|
}
|
|
789
793
|
const message = JSON.stringify({
|
|
790
|
-
type: "subtitle-cues", fileIndex, trackIndex, cues, language, detectedLanguage, cursor
|
|
794
|
+
type: "subtitle-cues", fileIndex, trackIndex, cues, withdrawn, language, detectedLanguage, cursor
|
|
791
795
|
});
|
|
792
796
|
let sent = 0;
|
|
793
797
|
for (const consumerId of names) {
|
|
@@ -257,6 +257,37 @@ export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, pla
|
|
|
257
257
|
return map;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Where viewers stand, read off a map: every second whose priority rises above
|
|
262
|
+
* the second before it.
|
|
263
|
+
*
|
|
264
|
+
* A viewer's own map is highest where they stand and falls away in front of
|
|
265
|
+
* them, so in a merged map each viewer's position is a step UP — the one place
|
|
266
|
+
* a value is higher than the value just behind it. A viewer standing inside the
|
|
267
|
+
* stretch somebody else is walking towards adds nothing that map does not
|
|
268
|
+
* already ask for, and is not a step; neither is somebody behind everybody.
|
|
269
|
+
*
|
|
270
|
+
* Read by whoever orders work by where people are and must not know what a
|
|
271
|
+
* viewer is — the subtitle walk reads the clusters at these times first.
|
|
272
|
+
*
|
|
273
|
+
* @param {PriorityMap | null | undefined} map
|
|
274
|
+
* @returns {number[]} Seconds, ascending.
|
|
275
|
+
*/
|
|
276
|
+
export function viewerStartsOn(map) {
|
|
277
|
+
const starts = [];
|
|
278
|
+
if (!map || !(map.durationSeconds > 0)) {
|
|
279
|
+
return starts;
|
|
280
|
+
}
|
|
281
|
+
for (let second = 0; second < map.durationSeconds; second += 1) {
|
|
282
|
+
const value = map.priority[second];
|
|
283
|
+
const before = second === 0 ? NOBODY_IS_COMING : map.priority[second - 1];
|
|
284
|
+
if (value > before && value > NOBODY_IS_COMING) {
|
|
285
|
+
starts.push(second);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return starts;
|
|
289
|
+
}
|
|
290
|
+
|
|
260
291
|
/**
|
|
261
292
|
* Every viewer's map as one.
|
|
262
293
|
*
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import test from "node:test";
|
|
3
|
-
import { audioReadingFor } from "../services/encode/audio-calibration.js";
|
|
4
|
-
|
|
5
|
-
const readings = [
|
|
6
|
-
{ codec: "ac3", operation: "aac", channels: 2, samplingFrequency: 48000, speed: 120 },
|
|
7
|
-
{ codec: "aac", operation: "copy", channels: 2, samplingFrequency: 48000, speed: 200,
|
|
8
|
-
bytes: 64000, durationSeconds: 4 }
|
|
9
|
-
];
|
|
10
|
-
|
|
11
|
-
test("audio processing uses codec and sample work instead of a video-copy rate", () => {
|
|
12
|
-
assert.equal(audioReadingFor(readings, { codec: "ac3", transcode: true, channels: 6,
|
|
13
|
-
samplingFrequency: 48000 }).speed, 40);
|
|
14
|
-
assert.equal(audioReadingFor(readings, { codec: "ac3", transcode: true, channels: 2,
|
|
15
|
-
samplingFrequency: 24000 }).speed, 240);
|
|
16
|
-
assert.equal(audioReadingFor(readings, { codec: "dts", transcode: true }), null);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("copying uses encoded byte work when the source declares its bitrate", () => {
|
|
20
|
-
assert.equal(audioReadingFor(readings, { codec: "aac", transcode: false, bitrateKbps: 256 }).speed, 100);
|
|
21
|
-
assert.equal(audioReadingFor(readings, { codec: "aac", transcode: false }).speed, 200);
|
|
22
|
-
assert.equal(audioReadingFor(readings, { codec: "aac", transcode: true }), null);
|
|
23
|
-
});
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { audioReadingFor } from "../services/encode/audio-calibration.js";
|
|
4
|
+
|
|
5
|
+
const readings = [
|
|
6
|
+
{ codec: "ac3", operation: "aac", channels: 2, samplingFrequency: 48000, speed: 120 },
|
|
7
|
+
{ codec: "aac", operation: "copy", channels: 2, samplingFrequency: 48000, speed: 200,
|
|
8
|
+
bytes: 64000, durationSeconds: 4 }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
test("audio processing uses codec and sample work instead of a video-copy rate", () => {
|
|
12
|
+
assert.equal(audioReadingFor(readings, { codec: "ac3", transcode: true, channels: 6,
|
|
13
|
+
samplingFrequency: 48000 }).speed, 40);
|
|
14
|
+
assert.equal(audioReadingFor(readings, { codec: "ac3", transcode: true, channels: 2,
|
|
15
|
+
samplingFrequency: 24000 }).speed, 240);
|
|
16
|
+
assert.equal(audioReadingFor(readings, { codec: "dts", transcode: true }), null);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("copying uses encoded byte work when the source declares its bitrate", () => {
|
|
20
|
+
assert.equal(audioReadingFor(readings, { codec: "aac", transcode: false, bitrateKbps: 256 }).speed, 100);
|
|
21
|
+
assert.equal(audioReadingFor(readings, { codec: "aac", transcode: false }).speed, 200);
|
|
22
|
+
assert.equal(audioReadingFor(readings, { codec: "aac", transcode: true }), null);
|
|
23
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A request that arrives during a run gives one more run, never a lost
|
|
3
|
+
* one and never a queue.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
|
|
9
|
+
import { coalescing } from "../utils/coalesce.js";
|
|
10
|
+
|
|
11
|
+
test("asked during a run: one more run after it, however often it was asked", async () => {
|
|
12
|
+
const runs = [];
|
|
13
|
+
let release;
|
|
14
|
+
const run = coalescing(async (label) => {
|
|
15
|
+
runs.push(label);
|
|
16
|
+
if (runs.length === 1) {
|
|
17
|
+
await new Promise((resolve) => {
|
|
18
|
+
release = resolve;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
const first = run("file", "first");
|
|
23
|
+
await run("file", "second");
|
|
24
|
+
await run("file", "third");
|
|
25
|
+
assert.deepEqual(runs, ["first"], "nothing ran beside the run in progress");
|
|
26
|
+
release();
|
|
27
|
+
await first;
|
|
28
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
29
|
+
assert.deepEqual(runs, ["first", "third"], "exactly one more run, with the latest arguments");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("different keys run side by side", async () => {
|
|
33
|
+
const runs = [];
|
|
34
|
+
const run = coalescing(async (label) => {
|
|
35
|
+
runs.push(label);
|
|
36
|
+
});
|
|
37
|
+
await Promise.all([run("a", "a"), run("b", "b")]);
|
|
38
|
+
assert.deepEqual(runs.sort(), ["a", "b"]);
|
|
39
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A read of what is downloaded asks for nothing, and hands the other
|
|
3
|
+
* thread memory that is its own.
|
|
4
|
+
*
|
|
5
|
+
* Until 2026-10-01 the read opened the torrent's own file stream on bytes that
|
|
6
|
+
* had not arrived; that stream selects pieces itself, outside the demand
|
|
7
|
+
* register, and waited up to thirty seconds — 36 such reads at a torrent's open
|
|
8
|
+
* competed with the file being watched. And the bytes it returns are now handed
|
|
9
|
+
* to the main thread instead of copied, which is safe only for a buffer that is
|
|
10
|
+
* the whole of its own memory. Fakes only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { Readable } from "node:stream";
|
|
16
|
+
import { MessageChannel } from "node:worker_threads";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
import { ownsItsMemory, readHeldBytes } from "../services/torrent/worker/held-bytes.js";
|
|
20
|
+
|
|
21
|
+
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
22
|
+
|
|
23
|
+
/** A torrent of one file whose stream serves views of one shared store buffer. */
|
|
24
|
+
function torrentOver(store, held) {
|
|
25
|
+
const streams = [];
|
|
26
|
+
const file = {
|
|
27
|
+
name: "film.mkv",
|
|
28
|
+
length: store.length,
|
|
29
|
+
offset: 0,
|
|
30
|
+
createReadStream({ start, end }) {
|
|
31
|
+
streams.push({ start, end });
|
|
32
|
+
return Readable.from([store.subarray(start, end + 1)]);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
streams,
|
|
37
|
+
torrent: { pieceLength: 16, bitfield: { get: (index) => held(index) }, files: [file] }
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("a range not wholly downloaded is answered at once with nothing, and no stream is opened", async () => {
|
|
42
|
+
const store = Buffer.alloc(64, 1);
|
|
43
|
+
const { torrent, streams } = torrentOver(store, (index) => index !== 2);
|
|
44
|
+
assert.equal(await readHeldBytes(torrent, 0, 0, 63), null);
|
|
45
|
+
assert.equal(streams.length, 0, "nothing was asked of the torrent");
|
|
46
|
+
assert.notEqual(await readHeldBytes(torrent, 0, 0, 31), null, "a range that is here is read");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("what a held read returns owns its memory; a pool slice and shared memory do not", async () => {
|
|
50
|
+
const store = Buffer.from(Array.from({ length: 64 }, (_, index) => index));
|
|
51
|
+
const { torrent } = torrentOver(store, () => true);
|
|
52
|
+
const bytes = await readHeldBytes(torrent, 0, 3, 40);
|
|
53
|
+
assert.equal(ownsItsMemory(bytes), true);
|
|
54
|
+
assert.equal(ownsItsMemory(Buffer.from("small")), false, "a slice of Node's pool");
|
|
55
|
+
assert.equal(ownsItsMemory(new Uint8Array(new SharedArrayBuffer(8))), false, "shared memory");
|
|
56
|
+
assert.equal(ownsItsMemory(store.subarray(4, 8)), false, "a view into another buffer");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("handing a read's bytes to another thread leaves the store and an overlapping read as they were", async () => {
|
|
60
|
+
const store = Buffer.from(Array.from({ length: 256 }, (_, index) => index % 251));
|
|
61
|
+
const before = digest(store);
|
|
62
|
+
const { torrent } = torrentOver(store, () => true);
|
|
63
|
+
const [first, second] = await Promise.all([readHeldBytes(torrent, 0, 10, 200), readHeldBytes(torrent, 0, 50, 120)]);
|
|
64
|
+
const secondBefore = digest(second);
|
|
65
|
+
|
|
66
|
+
const { port1, port2 } = new MessageChannel();
|
|
67
|
+
const received = new Promise((resolve) => port2.once("message", resolve));
|
|
68
|
+
port1.postMessage({ bytes: first }, [first.buffer]);
|
|
69
|
+
const message = await received;
|
|
70
|
+
port1.close();
|
|
71
|
+
port2.close();
|
|
72
|
+
|
|
73
|
+
assert.equal(first.byteLength, 0, "the sender no longer holds the memory it handed over");
|
|
74
|
+
assert.deepEqual(Buffer.from(message.bytes), store.subarray(10, 201), "the receiver has every byte");
|
|
75
|
+
assert.equal(digest(store), before, "the store's own bytes are untouched");
|
|
76
|
+
assert.equal(digest(second), secondBefore, "the overlapping read is untouched");
|
|
77
|
+
});
|
|
@@ -5,25 +5,73 @@
|
|
|
5
5
|
* (`torrent/worker/held-bytes.js`), so a check that walks cues exercises both
|
|
6
6
|
* sides of the seam rather than a stand-in for one of them: the bitfield
|
|
7
7
|
* becoming a list of ranges, and a range read that never fetches.
|
|
8
|
+
*
|
|
9
|
+
* The file's container is the one a `ContainerOrchestrator` builds, over a
|
|
10
|
+
* reader that FETCHES — the fake file's own stream, which serves any range —
|
|
11
|
+
* because the head and the Cues table are read that way in the product.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import { heldRangesOf, readHeldBytes } from "../../services/torrent/worker/held-bytes.js";
|
|
15
|
+
import { ContainerOrchestrator } from "../../services/media/ContainerOrchestrator.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every byte of a range from the fake file's stream.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} file
|
|
21
|
+
* @param {number} start
|
|
22
|
+
* @param {number} end
|
|
23
|
+
* @returns {Promise<Buffer>}
|
|
24
|
+
*/
|
|
25
|
+
async function readThroughStream(file, start, end) {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for await (const chunk of file.createReadStream({ start, end })) {
|
|
28
|
+
chunks.push(Buffer.from(chunk));
|
|
29
|
+
}
|
|
30
|
+
return Buffer.concat(chunks);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One orchestrator for the checks that do not bring their own. */
|
|
34
|
+
const sharedContainers = new ContainerOrchestrator();
|
|
11
35
|
|
|
12
36
|
/**
|
|
13
37
|
* @param {object} torrent - The fake a check builds: `pieceLength`, `bitfield`,
|
|
14
38
|
* `files[]` with `offset`, `length`, `name`, `createReadStream`.
|
|
15
39
|
* @param {number} fileIndex
|
|
16
40
|
* @param {string} sourceKey
|
|
41
|
+
* @param {{ containers?: ContainerOrchestrator, readRange?: (start: number, end: number) => Promise<Buffer | null>, wantedSeconds?: () => number[], portionBytes?: number }} [options]
|
|
17
42
|
* @returns {import("../../services/media/SubtitleCues.js").HeldFile}
|
|
18
43
|
*/
|
|
19
|
-
export function heldFileOver(torrent, fileIndex, sourceKey) {
|
|
44
|
+
export function heldFileOver(torrent, fileIndex, sourceKey, options = {}) {
|
|
20
45
|
const file = torrent?.files?.[fileIndex] ?? {};
|
|
46
|
+
const containers = options.containers ?? sharedContainers;
|
|
47
|
+
const readRange = options.readRange ?? ((start, end) => readThroughStream(file, start, end));
|
|
21
48
|
return {
|
|
22
49
|
sourceKey,
|
|
23
50
|
fileIndex,
|
|
24
51
|
name: String(file.name ?? ""),
|
|
25
52
|
length: Number(file.length) || 0,
|
|
53
|
+
portionBytes: options.portionBytes,
|
|
54
|
+
container: () =>
|
|
55
|
+
containers.containerFor({
|
|
56
|
+
sourceKey,
|
|
57
|
+
fileIndex,
|
|
58
|
+
readRange,
|
|
59
|
+
fileSize: Number(file.length) || 0,
|
|
60
|
+
label: String(file.name ?? ""),
|
|
61
|
+
portionBytes: options.portionBytes
|
|
62
|
+
}),
|
|
26
63
|
heldRanges: async () => heldRangesOf(torrent, fileIndex),
|
|
27
|
-
readHeld: (start, end) => readHeldBytes(torrent, fileIndex, start, end)
|
|
64
|
+
readHeld: (start, end) => readHeldBytes(torrent, fileIndex, start, end),
|
|
65
|
+
wantedSeconds: options.wantedSeconds
|
|
28
66
|
};
|
|
29
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Forget what the shared orchestrator built for a source.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} sourceKey
|
|
73
|
+
* @returns {void}
|
|
74
|
+
*/
|
|
75
|
+
export function forgetContainers(sourceKey) {
|
|
76
|
+
sharedContainers.forget(sourceKey);
|
|
77
|
+
}
|