@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,77 +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
|
-
});
|
|
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
|
+
});
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* product builds and injects only the one thing a test may not have, which is a
|
|
9
9
|
* child process.
|
|
10
10
|
*
|
|
11
|
-
* The pid
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* The fake has a pid, as a started process does: a run signals only a process
|
|
12
|
+
* that started. The number is never used to signal anything — every signal goes
|
|
13
|
+
* through the child object, and this one records it.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { EncodeRun } from "../../services/encode/EncodeRun.js";
|
|
@@ -92,11 +92,10 @@ function say(channel, text) {
|
|
|
92
92
|
* A child process that records what was done to it.
|
|
93
93
|
*
|
|
94
94
|
* @param {object} [options]
|
|
95
|
-
* @param {number | null} [options.pid] -
|
|
96
|
-
* suspend or resume, and then it must be this process's own.
|
|
95
|
+
* @param {number | null} [options.pid] - Null for a process that did not start.
|
|
97
96
|
* @returns {object}
|
|
98
97
|
*/
|
|
99
|
-
export function fakeProcess({ pid =
|
|
98
|
+
export function fakeProcess({ pid = 4242, exitsWhenKilled = true } = {}) {
|
|
100
99
|
/** @type {Map<string, (...args: unknown[]) => void>} */
|
|
101
100
|
const listeners = new Map();
|
|
102
101
|
const stdout = channel();
|
|
@@ -123,6 +122,10 @@ export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
|
|
|
123
122
|
},
|
|
124
123
|
kill(signal = "SIGTERM") {
|
|
125
124
|
this.signals.push(signal);
|
|
125
|
+
// Suspending and continuing are not ends.
|
|
126
|
+
if (signal === "SIGSTOP" || signal === "SIGCONT") {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
126
129
|
this.killed = true;
|
|
127
130
|
// A real process answers a signal by exiting, and it does so on a later
|
|
128
131
|
// turn. A fake that never exits makes every disposal wait out the grace
|
|
@@ -144,6 +147,11 @@ export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
|
|
|
144
147
|
for (const handler of listeners.get("exit") ?? []) {
|
|
145
148
|
handler(code, signal);
|
|
146
149
|
}
|
|
150
|
+
// A real child process emits `close` after `exit`, once its stdio has
|
|
151
|
+
// closed, and the run takes its ending from `close`.
|
|
152
|
+
for (const handler of listeners.get("close") ?? []) {
|
|
153
|
+
handler(code, signal);
|
|
154
|
+
}
|
|
147
155
|
}
|
|
148
156
|
};
|
|
149
157
|
}
|
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file A torrent reduced to what the subtitle walk may know about one file.
|
|
3
|
-
*
|
|
4
|
-
* Built from the REAL two functions the torrent thread answers with
|
|
5
|
-
* (`torrent/worker/held-bytes.js`), so a check that walks cues exercises both
|
|
6
|
-
* sides of the seam rather than a stand-in for one of them: the bitfield
|
|
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.
|
|
12
|
-
*/
|
|
13
|
-
|
|
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();
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @param {object} torrent - The fake a check builds: `pieceLength`, `bitfield`,
|
|
38
|
-
* `files[]` with `offset`, `length`, `name`, `createReadStream`.
|
|
39
|
-
* @param {number} fileIndex
|
|
40
|
-
* @param {string} sourceKey
|
|
41
|
-
* @param {{ containers?: ContainerOrchestrator, readRange?: (start: number, end: number) => Promise<Buffer | null>, wantedSeconds?: () => number[], portionBytes?: number }} [options]
|
|
42
|
-
* @returns {import("../../services/media/SubtitleCues.js").HeldFile}
|
|
43
|
-
*/
|
|
44
|
-
export function heldFileOver(torrent, fileIndex, sourceKey, options = {}) {
|
|
45
|
-
const file = torrent?.files?.[fileIndex] ?? {};
|
|
46
|
-
const containers = options.containers ?? sharedContainers;
|
|
47
|
-
const readRange = options.readRange ?? ((start, end) => readThroughStream(file, start, end));
|
|
48
|
-
return {
|
|
49
|
-
sourceKey,
|
|
50
|
-
fileIndex,
|
|
51
|
-
name: String(file.name ?? ""),
|
|
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
|
-
}),
|
|
63
|
-
heldRanges: async () => heldRangesOf(torrent, fileIndex),
|
|
64
|
-
readHeld: (start, end) => readHeldBytes(torrent, fileIndex, start, end),
|
|
65
|
-
wantedSeconds: options.wantedSeconds
|
|
66
|
-
};
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file A torrent reduced to what the subtitle walk may know about one file.
|
|
3
|
+
*
|
|
4
|
+
* Built from the REAL two functions the torrent thread answers with
|
|
5
|
+
* (`torrent/worker/held-bytes.js`), so a check that walks cues exercises both
|
|
6
|
+
* sides of the seam rather than a stand-in for one of them: the bitfield
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
|
|
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();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} torrent - The fake a check builds: `pieceLength`, `bitfield`,
|
|
38
|
+
* `files[]` with `offset`, `length`, `name`, `createReadStream`.
|
|
39
|
+
* @param {number} fileIndex
|
|
40
|
+
* @param {string} sourceKey
|
|
41
|
+
* @param {{ containers?: ContainerOrchestrator, readRange?: (start: number, end: number) => Promise<Buffer | null>, wantedSeconds?: () => number[], portionBytes?: number }} [options]
|
|
42
|
+
* @returns {import("../../services/media/SubtitleCues.js").HeldFile}
|
|
43
|
+
*/
|
|
44
|
+
export function heldFileOver(torrent, fileIndex, sourceKey, options = {}) {
|
|
45
|
+
const file = torrent?.files?.[fileIndex] ?? {};
|
|
46
|
+
const containers = options.containers ?? sharedContainers;
|
|
47
|
+
const readRange = options.readRange ?? ((start, end) => readThroughStream(file, start, end));
|
|
48
|
+
return {
|
|
49
|
+
sourceKey,
|
|
50
|
+
fileIndex,
|
|
51
|
+
name: String(file.name ?? ""),
|
|
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
|
+
}),
|
|
63
|
+
heldRanges: async () => heldRangesOf(torrent, fileIndex),
|
|
64
|
+
readHeld: (start, end) => readHeldBytes(torrent, fileIndex, start, end),
|
|
65
|
+
wantedSeconds: options.wantedSeconds
|
|
66
|
+
};
|
|
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
|
+
}
|
package/test/helpers/manager.js
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file A session manager for a test, with a store of its own.
|
|
3
|
-
*
|
|
4
|
-
* ONE MANAGER, ONE ROOT. In production that is true by construction: one
|
|
5
|
-
* process holds one manager, and the store's root is a fixed path so that a
|
|
6
|
-
* restart can find what the process before it finished (`SegmentStore.sweep`).
|
|
7
|
-
* Under `node --test` it is false — the runner starts one PROCESS PER FILE and
|
|
8
|
-
* runs them in parallel, so every manager built without a store of its own
|
|
9
|
-
* shared the one default root with every other file running at that moment.
|
|
10
|
-
*
|
|
11
|
-
* What that cost, measured 2026-09-14: `disposeAll` removes the store's whole
|
|
12
|
-
* root, and four files call it between them 40 times. Running the twenty-one
|
|
13
|
-
* files that build a manager gave 2, 3 and 4 failures on consecutive runs and
|
|
14
|
-
* zero with `--test-concurrency=1`; the victim was always
|
|
15
|
-
* `segment-serve-wiring.test.js`, reporting a segment missing that it had
|
|
16
|
-
* written itself a moment earlier, because another process had deleted the
|
|
17
|
-
* directory under it. `npm test` is `node --test`, so this was the project's
|
|
18
|
-
* own test command, and it is why a red result had become something to read
|
|
19
|
-
* past rather than to act on.
|
|
20
|
-
*
|
|
21
|
-
* A test that fails by luck is worse than one that does not exist: it costs a
|
|
22
|
-
* run every time, and it trains everyone to ignore red.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
26
|
-
import os from "node:os";
|
|
27
|
-
import path from "node:path";
|
|
28
|
-
import { wireOutputs } from "../../services/server/wire-outputs.js";
|
|
29
|
-
import { SegmentStore } from "../../services/storage/segment-store/SegmentStore.js";
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* A manager whose produced segments live where nothing else can reach them.
|
|
33
|
-
*
|
|
34
|
-
* @param {object} [options] - Passed to the manager, over the defaults below.
|
|
35
|
-
* A `segmentStore` given here wins, for a test that wants to watch the store
|
|
36
|
-
* itself.
|
|
37
|
-
* @returns {{ manager: object, store: SegmentStore, root: string,
|
|
38
|
-
* cleanup: () => void }} `cleanup` removes the root; call it from `t.after`.
|
|
39
|
-
*/
|
|
40
|
-
export function managerWithOwnStore(options = {}) {
|
|
41
|
-
const root = mkdtempSync(path.join(os.tmpdir(), "ttv-store-"));
|
|
42
|
-
const store = options.segmentStore ?? new SegmentStore({ root });
|
|
43
|
-
const manager = wireOutputs({
|
|
44
|
-
enabled: true,
|
|
45
|
-
ffmpegBin: "ffmpeg",
|
|
46
|
-
localBindHost: "127.0.0.1",
|
|
47
|
-
localPort: 9090,
|
|
48
|
-
// What this host has measured about itself is kept beside the store, not
|
|
49
|
-
// beside the installed proxy: read from there, one run's figures became
|
|
50
|
-
// the next run's premises, and a check passed only on a machine that had
|
|
51
|
-
// run the suite before.
|
|
52
|
-
stateDir: root,
|
|
53
|
-
...options,
|
|
54
|
-
segmentStore: store
|
|
55
|
-
});
|
|
56
|
-
return {
|
|
57
|
-
manager,
|
|
58
|
-
store,
|
|
59
|
-
root,
|
|
60
|
-
cleanup: () => rmSync(root, { recursive: true, force: true })
|
|
61
|
-
};
|
|
62
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file A session manager for a test, with a store of its own.
|
|
3
|
+
*
|
|
4
|
+
* ONE MANAGER, ONE ROOT. In production that is true by construction: one
|
|
5
|
+
* process holds one manager, and the store's root is a fixed path so that a
|
|
6
|
+
* restart can find what the process before it finished (`SegmentStore.sweep`).
|
|
7
|
+
* Under `node --test` it is false — the runner starts one PROCESS PER FILE and
|
|
8
|
+
* runs them in parallel, so every manager built without a store of its own
|
|
9
|
+
* shared the one default root with every other file running at that moment.
|
|
10
|
+
*
|
|
11
|
+
* What that cost, measured 2026-09-14: `disposeAll` removes the store's whole
|
|
12
|
+
* root, and four files call it between them 40 times. Running the twenty-one
|
|
13
|
+
* files that build a manager gave 2, 3 and 4 failures on consecutive runs and
|
|
14
|
+
* zero with `--test-concurrency=1`; the victim was always
|
|
15
|
+
* `segment-serve-wiring.test.js`, reporting a segment missing that it had
|
|
16
|
+
* written itself a moment earlier, because another process had deleted the
|
|
17
|
+
* directory under it. `npm test` is `node --test`, so this was the project's
|
|
18
|
+
* own test command, and it is why a red result had become something to read
|
|
19
|
+
* past rather than to act on.
|
|
20
|
+
*
|
|
21
|
+
* A test that fails by luck is worse than one that does not exist: it costs a
|
|
22
|
+
* run every time, and it trains everyone to ignore red.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { wireOutputs } from "../../services/server/wire-outputs.js";
|
|
29
|
+
import { SegmentStore } from "../../services/storage/segment-store/SegmentStore.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A manager whose produced segments live where nothing else can reach them.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} [options] - Passed to the manager, over the defaults below.
|
|
35
|
+
* A `segmentStore` given here wins, for a test that wants to watch the store
|
|
36
|
+
* itself.
|
|
37
|
+
* @returns {{ manager: object, store: SegmentStore, root: string,
|
|
38
|
+
* cleanup: () => void }} `cleanup` removes the root; call it from `t.after`.
|
|
39
|
+
*/
|
|
40
|
+
export function managerWithOwnStore(options = {}) {
|
|
41
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "ttv-store-"));
|
|
42
|
+
const store = options.segmentStore ?? new SegmentStore({ root });
|
|
43
|
+
const manager = wireOutputs({
|
|
44
|
+
enabled: true,
|
|
45
|
+
ffmpegBin: "ffmpeg",
|
|
46
|
+
localBindHost: "127.0.0.1",
|
|
47
|
+
localPort: 9090,
|
|
48
|
+
// What this host has measured about itself is kept beside the store, not
|
|
49
|
+
// beside the installed proxy: read from there, one run's figures became
|
|
50
|
+
// the next run's premises, and a check passed only on a machine that had
|
|
51
|
+
// run the suite before.
|
|
52
|
+
stateDir: root,
|
|
53
|
+
...options,
|
|
54
|
+
segmentStore: store
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
manager,
|
|
58
|
+
store,
|
|
59
|
+
root,
|
|
60
|
+
cleanup: () => rmSync(root, { recursive: true, force: true })
|
|
61
|
+
};
|
|
62
|
+
}
|