@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.
Files changed (45) hide show
  1. package/AGENTS.md +21 -12
  2. package/CHANGELOG.md +17 -3
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/docs/container-architecture.md +77 -22
  6. package/docs/playback-start-forecast.md +56 -56
  7. package/main2.cpuprofile +1 -0
  8. package/package.json +1 -1
  9. package/scripts/build-audio-samples.mjs +31 -31
  10. package/server.js +42 -27
  11. package/services/encode/OutputOpening.js +5 -4
  12. package/services/encode/audio-calibration.js +67 -67
  13. package/services/encode/audio-work-rate.js +15 -15
  14. package/services/media/ContainerOrchestrator.js +99 -46
  15. package/services/media/KeyframeTables.js +61 -21
  16. package/services/media/SubtitleCues.js +255 -183
  17. package/services/media/container/AviContainer.js +5 -3
  18. package/services/media/container/Container.js +66 -43
  19. package/services/media/container/ContainerFactory.js +8 -26
  20. package/services/media/container/MatroskaContainer.js +719 -1182
  21. package/services/media/container/Mp4Container.js +83 -73
  22. package/services/media/container/ebml-stream.js +174 -0
  23. package/services/media/container/matroska-clusters.js +1165 -0
  24. package/services/media/container/unavailable.js +77 -0
  25. package/services/media/tracks/ContainerTrack.js +9 -0
  26. package/services/media/tracks/TextSubtitleTrack.js +9 -1
  27. package/services/server/playback-readiness.js +516 -516
  28. package/services/torrent/worker/client.js +6 -2
  29. package/services/torrent/worker/held-bytes.js +84 -8
  30. package/services/torrent/worker/worker.js +10 -2
  31. package/services/transport/data-channel-handler.js +7 -3
  32. package/services/viewer/PriorityMap.js +31 -0
  33. package/test/audio-calibration.test.js +23 -23
  34. package/test/coalesce.test.js +39 -0
  35. package/test/held-read-ownership.test.js +77 -0
  36. package/test/helpers/held-file.js +50 -2
  37. package/test/helpers/matroska-file.js +296 -0
  38. package/test/keyframe-table-budget.test.js +21 -0
  39. package/test/matroska-cluster-discovery.test.js +323 -0
  40. package/test/reads-not-yet-arrived.test.js +218 -0
  41. package/test/subtitle-cue-walk.test.js +16 -1
  42. package/test/subtitle-plan-waits.test.js +125 -0
  43. package/test/subtitle-track-numbering.test.js +8 -3
  44. package/test/viewer-starts-on-map.test.js +27 -0
  45. 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
- return answer?.bytes ? Buffer.from(answer.bytes) : null;
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
- * How long a read of bytes the torrent already holds may take before it is
92
- * given up.
91
+ * Whether every piece under an inclusive range of a file has arrived.
93
92
  *
94
- * Not a measurement and nothing is derived from it: such a read either answers
95
- * or it does not, and this is the point past which it is presumed lost — so
96
- * that one stream which never ends cannot hold a file's walk, and with it the
97
- * browser's own request for its subtitles, for the rest of the session.
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
- `did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
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(Buffer.concat(chunks)));
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
- parentPort.postMessage({ type: Event.RESULT, id, result });
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
+ }