@torrent-tv/proxy 2.72.2 → 2.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,6 @@
13
13
 
14
14
  import { containerOrchestrator } from "./ContainerOrchestrator.js";
15
15
  import {
16
- cuesHeldFor as domainCuesHeldFor,
17
16
  warmSubtitleCues as domainWarm,
18
17
  subtitleTracksOf,
19
18
  declaredSubtitleTracksOf,
@@ -65,18 +64,78 @@ export class SubtitleOrchestrator {
65
64
  }
66
65
 
67
66
  /**
68
- * Cues already downloaded for one track.
67
+ * Cues already downloaded for one track — ASKED OF THE TORRENT WORKER, never
68
+ * walked here.
69
+ *
70
+ * The walk decides what it may read from `torrent.bitfield` and
71
+ * `torrent.pieceLength`, and a torrent stand-in on the main thread has
72
+ * neither: it carries `infoHash`, `name` and a `files` list whose reads go
73
+ * back across the boundary (`torrent-worker/client.js`). So the same code
74
+ * called here answers that nothing is downloaded, walks no clusters, and
75
+ * returns an empty document — which is not a failure anything reports,
76
+ * because "no cues held" is a legitimate answer.
77
+ *
78
+ * Field 2026-09-03, and it is the whole reason this method changed. An
79
+ * episode already downloaded from an earlier sitting had its cues found
80
+ * within a second and a half of the file being opened, and the first four
81
+ * pushes — everything before 81.7 s — went out before the browser had
82
+ * subscribed. The catch-up pull that exists for exactly that case answered
83
+ * `WEBVTT` and nothing else, with `x-subtitle-covered-clusters: 0` against
84
+ * 283 indexed, so the viewer watched the opening of the episode with no
85
+ * subtitles and the rest of it with them.
86
+ *
87
+ * There is a second reason, independent of the bitfield. The register of what
88
+ * has been walked, what has been found and in what ORDER lives in the module
89
+ * that does the walking, and the worker already keeps one — the push path
90
+ * fills it. Walking again on the main thread would build a SECOND register
91
+ * with its own `seq` counter, and the browser mixes the cursors from both
92
+ * paths (`#rememberCursor`): two counters would make a cursor from a pull and
93
+ * a cursor from a push incomparable. One register, one walk, one cursor.
94
+ *
95
+ * @param {{ getSubtitleCues?: Function }} pool - The torrent pool, which is
96
+ * what holds the channel to the worker.
69
97
  * @param {object} torrent
70
98
  * @param {number} fileIndex
71
99
  * @param {string} sourceKey
72
100
  * @param {number} trackNumber - Container trackNumber
101
+ * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
73
102
  */
74
- async getCues(torrent, fileIndex, sourceKey, trackNumber) {
103
+ async getCues(pool, torrent, fileIndex, sourceKey, trackNumber) {
104
+ // Built fresh on each of the three paths that need it. One shared literal
105
+ // returned by reference would hand every caller the same array, and a
106
+ // single one of them appending to it would change what the next caller
107
+ // reads — which in a method about cue registers not leaking into each
108
+ // other would be a poor thing to introduce.
109
+ const empty = () => ({ cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
110
+ if (typeof pool?.getSubtitleCues !== "function") {
111
+ // Nothing here can read pieces, and answering an empty document would be
112
+ // indistinguishable from a file that genuinely holds no cues.
113
+ logger.warn(
114
+ "subtitle-orchestrator: the torrent pool cannot be asked for cues, " +
115
+ "so none can be served — the walk needs the thread that owns the torrent"
116
+ );
117
+ return empty();
118
+ }
75
119
  try {
76
- return await domainCuesHeldFor(torrent, fileIndex, sourceKey, trackNumber);
120
+ const answer = await pool.getSubtitleCues(torrent, fileIndex, trackNumber);
121
+ if (!answer) {
122
+ return empty();
123
+ }
124
+ return {
125
+ cues: Array.isArray(answer.cues) ? answer.cues : [],
126
+ coveredClusters: answer.coveredClusters ?? 0,
127
+ indexedClusters: answer.indexedClusters ?? 0,
128
+ // The worker answers with the track's own fields flat, because a
129
+ // `ContainerTrack` is a class and only plain objects cross the boundary.
130
+ track: {
131
+ codecId: answer.codecId ?? "",
132
+ codecPrivate: answer.codecPrivate ?? "",
133
+ language: answer.language ?? ""
134
+ }
135
+ };
77
136
  } catch (e) {
78
137
  logger.warn(`subtitle-orchestrator: getCues failed: ${e?.message ?? e}`);
79
- return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
138
+ return empty();
80
139
  }
81
140
  }
82
141
 
@@ -1800,6 +1800,52 @@ export class TorrentPool {
1800
1800
  }
1801
1801
  }
1802
1802
 
1803
+ /**
1804
+ * Fetch a bounded region in the MIDDLE of a file.
1805
+ *
1806
+ * The warm-up fetches a file's two edges because the codec probe reads them.
1807
+ * A viewer resuming a film needs neither: they need the region under their own
1808
+ * position, and until now nothing asked for it before the encoder did. Field
1809
+ * 2026-09-03 — a retry after a crash reached the encoder 53 s after the button
1810
+ * was pressed, and only THEN was the piece under the viewer's position first
1811
+ * requested; it took another 46 s, and the browser gave up 0.4 s before it
1812
+ * landed.
1813
+ *
1814
+ * Read as an ordinary bounded read, never as a selection: claiming a whole
1815
+ * region alongside the readers' own windows is the mistake `#syncSelections`
1816
+ * was written against.
1817
+ *
1818
+ * @param {import("webtorrent").Torrent} torrent
1819
+ * @param {number} fileIndex
1820
+ * @param {number} startByte
1821
+ * @param {number} bytes
1822
+ * @param {{ timeoutMs?: number }} [options]
1823
+ * @returns {Promise<void>}
1824
+ */
1825
+ async prefetchFileRegion(torrent, fileIndex, startByte, bytes, { timeoutMs = 300_000 } = {}) {
1826
+ const file = torrent?.files?.[fileIndex];
1827
+ if (!file || typeof file.createReadStream !== "function") {
1828
+ return;
1829
+ }
1830
+ const fileSize = file.length;
1831
+ if (!Number.isFinite(fileSize) || fileSize <= 0 || !(bytes > 0)) {
1832
+ return;
1833
+ }
1834
+ const start = Math.max(0, Math.min(Math.floor(startByte), fileSize - 1));
1835
+ const end = Math.min(fileSize - 1, start + Math.floor(bytes) - 1);
1836
+ if (end <= start) {
1837
+ return;
1838
+ }
1839
+ const drained = new Promise((resolve) => {
1840
+ const stream = file.createReadStream({ start, end });
1841
+ stream.on("data", () => undefined);
1842
+ stream.once("end", resolve);
1843
+ stream.once("error", resolve);
1844
+ stream.once("close", resolve);
1845
+ });
1846
+ await Promise.race([drained, new Promise((resolve) => setTimeout(resolve, timeoutMs))]);
1847
+ }
1848
+
1803
1849
  /**
1804
1850
  * The body of {@link prefetchFileEdges}, without the de-duplication.
1805
1851
  *
@@ -349,6 +349,27 @@ export class TorrentWorkerClient {
349
349
  return this.#caller.call(Command.CONTAINER_TRACKS, { sourceKey, fileIndex });
350
350
  }
351
351
 
352
+ /**
353
+ * What one file declares about itself — format, duration, and where its own
354
+ * timeline begins.
355
+ *
356
+ * @param {{ sourceKey: string, fileIndex: number }} params
357
+ * @returns {Promise<{ info: import("../container/Container.js").ContainerMediaInfo | null }>}
358
+ */
359
+ async getContainerMediaInfo({ sourceKey, fileIndex }) {
360
+ return this.#caller.call(Command.CONTAINER_MEDIA_INFO, { sourceKey, fileIndex });
361
+ }
362
+
363
+ /**
364
+ * Start fetching the region a viewer is about to resume at.
365
+ *
366
+ * @param {{ sourceKey: string, fileIndex: number, positionSeconds: number }} params
367
+ * @returns {Promise<{ started: boolean }>}
368
+ */
369
+ async warmResumePosition({ sourceKey, fileIndex, positionSeconds }) {
370
+ return this.#caller.call(Command.WARM_POSITION, { sourceKey, fileIndex, positionSeconds });
371
+ }
372
+
352
373
  /**
353
374
  * The cues of one subtitle track that can be read from what is downloaded.
354
375
  *
@@ -224,6 +224,140 @@ export async function containerAudioTracksOf(torrent, fileIndex, sourceKey, opti
224
224
  /** Bytes of a file's head worth fetching before its track table is read. */
225
225
  export const CONTAINER_HEAD_BYTES = HEAD_BYTES;
226
226
 
227
+ /**
228
+ * How much of the file to pull in under the viewer's resume position.
229
+ *
230
+ * One piece of a video torrent is 4-16 MB and a resume lands anywhere inside
231
+ * one, so anything smaller would still leave the encoder waiting for the piece
232
+ * it starts in. Eight megabytes covers that piece and usually the next.
233
+ */
234
+ const RESUME_REGION_BYTES = 8 * 1024 * 1024;
235
+
236
+ /**
237
+ * Where a position in seconds falls in a file, in bytes.
238
+ *
239
+ * Proportional, and therefore approximate on a variable bitrate — which is what
240
+ * it is for: a prefetch that puts the swarm to work on roughly the right place
241
+ * while the plan and the session are still being built. The encoder's own read
242
+ * asks for the exact bytes a moment later and corrects it.
243
+ *
244
+ * A position past the end is clamped to the end rather than refused: a resume
245
+ * position can outlive the file it was recorded against, and reading the last
246
+ * bytes is harmless where reading past them is an error.
247
+ *
248
+ * @param {number} fileLength
249
+ * @param {number} durationSeconds
250
+ * @param {number} positionSeconds
251
+ * @returns {number}
252
+ */
253
+ export function resumeByteOffset(fileLength, durationSeconds, positionSeconds) {
254
+ if (!(fileLength > 0) || !(durationSeconds > 0) || !(positionSeconds > 0)) {
255
+ return 0;
256
+ }
257
+ const within = Math.min(positionSeconds, durationSeconds);
258
+ return Math.min(fileLength - 1, Math.floor((fileLength * within) / durationSeconds));
259
+ }
260
+
261
+ /**
262
+ * Start fetching the region a viewer is about to resume at.
263
+ *
264
+ * Where that region IS can only be worked out from two numbers the file itself
265
+ * holds — its length and its duration — so this belongs beside the container
266
+ * read rather than in the route: the route knows a position in seconds and
267
+ * nothing else. The conversion is proportional and therefore approximate on a
268
+ * variable bitrate; it is a prefetch, and the encoder's own read corrects it.
269
+ *
270
+ * @param {object} torrent
271
+ * @param {number} fileIndex
272
+ * @param {string} sourceKey
273
+ * @param {number} positionSeconds
274
+ * @param {{ prefetchEdges?: () => Promise<unknown>, fetchRegion?: (start: number, bytes: number) => Promise<unknown> }} options
275
+ * @returns {Promise<boolean>} Whether a region was asked for.
276
+ */
277
+ export async function warmResumePosition(torrent, fileIndex, sourceKey, positionSeconds, options = {}) {
278
+ const file = torrent?.files?.[fileIndex];
279
+ if (!file || !(positionSeconds > 0) || typeof options.fetchRegion !== "function") {
280
+ return false;
281
+ }
282
+ const info = await containerMediaInfoOf(torrent, fileIndex, sourceKey, options);
283
+ const duration = info?.durationSeconds;
284
+ if (!Number.isFinite(duration) || duration <= 0) {
285
+ logger.info(
286
+ `warm ${sourceKey.slice(0, 8)}: "${String(file.name).slice(0, 40)}" does not declare its ` +
287
+ "duration, so where the viewer's position falls in it cannot be worked out — " +
288
+ "the region under it is left to the encoder's own read"
289
+ );
290
+ return false;
291
+ }
292
+ const at = resumeByteOffset(file.length, duration, positionSeconds);
293
+ logger.info(
294
+ `warm ${sourceKey.slice(0, 8)}: fetching ${(RESUME_REGION_BYTES / (1024 * 1024)).toFixed(0)}MB under the ` +
295
+ `viewer's position ${positionSeconds.toFixed(1)}s of ${duration.toFixed(1)}s, which is ` +
296
+ `${(at / (1024 * 1024)).toFixed(1)}MB into "${String(file.name).slice(0, 40)}"`
297
+ );
298
+ await options.fetchRegion(at, RESUME_REGION_BYTES);
299
+ return true;
300
+ }
301
+
302
+ /**
303
+ * What one file declares about itself: format, duration, and where its own
304
+ * timeline begins.
305
+ *
306
+ * The same header the track table is read from, and the container instance is
307
+ * cached per file, so asking for this after the tracks costs no read at all.
308
+ * It exists because the alternative was a second reader: the session manager
309
+ * used to spawn an ffmpeg over the proxy's own HTTP to learn where a sidecar
310
+ * soundtrack's timeline begins, and that read cost 8121 ms in the field on
311
+ * 2026-09-03 while this layer had read the same header in 8 ms in the same
312
+ * second.
313
+ *
314
+ * @param {object} torrent
315
+ * @param {number} fileIndex
316
+ * @param {string} sourceKey
317
+ * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
318
+ * @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
319
+ */
320
+ export async function containerMediaInfoOf(torrent, fileIndex, sourceKey, options = {}) {
321
+ const file = torrent?.files?.[fileIndex];
322
+ if (!file || !Number.isFinite(file.length) || file.length <= 0) {
323
+ return null;
324
+ }
325
+ if (typeof options.prefetchEdges === "function") {
326
+ try {
327
+ await options.prefetchEdges();
328
+ } catch {
329
+ // A prefetch that failed is not a reason to skip the read: the read
330
+ // fetches what it needs itself, only more slowly.
331
+ }
332
+ }
333
+ const readRange = async (start, end) =>
334
+ readFetching(file, start, Math.min(end, file.length - 1));
335
+ try {
336
+ const info = await containerOrchestrator.getMediaInfo({
337
+ sourceKey,
338
+ fileIndex,
339
+ readRange,
340
+ fileSize: file.length,
341
+ label: String(file.name ?? "")
342
+ });
343
+ if (info) {
344
+ logger.info(
345
+ `container-info: "${String(file.name).slice(0, 40)}" is ${info.format}, ` +
346
+ `${info.durationSeconds === null ? "duration not declared" : `${info.durationSeconds.toFixed(3)}s`}, ` +
347
+ `${info.startTimeSeconds === null
348
+ ? "start of its timeline not declared"
349
+ : `its timeline starts at ${info.startTimeSeconds.toFixed(6)}s`}`
350
+ );
351
+ }
352
+ return info;
353
+ } catch (error) {
354
+ logger.warn(
355
+ `container-info: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
356
+ );
357
+ return null;
358
+ }
359
+ }
360
+
227
361
  /**
228
362
  * Forget one file's tracks, or every file of a source.
229
363
  *
@@ -24,6 +24,19 @@
24
24
  * instead of staying with whoever got it first.
25
25
  */
26
26
 
27
+ /**
28
+ * How short a piece's remaining tail must be for a second copy of it to be
29
+ * worth asking for.
30
+ *
31
+ * Sixteen blocks is 256 KB against the 4-16 MB piece they hold up. The figure
32
+ * comes from what was measured rather than chosen for roundness: the tails a
33
+ * blocked reader waits on were 2 to 14 blocks of 512 on 2026-08-19, and 3 of
34
+ * 512 in the field failure of 2026-09-03 that took 46.3 s. Above this the piece
35
+ * is still arriving normally and duplicating would spend the shared link on
36
+ * bytes already on their way.
37
+ */
38
+ const SHORT_TAIL_BLOCKS = 16;
39
+
27
40
  /**
28
41
  * The library's own request entry. Internal, so its absence must be noticed
29
42
  * rather than swallowed: without it this lever silently does nothing.
@@ -269,6 +282,15 @@ export function duplicateTailFor(torrent, pieceIndex) {
269
282
  return { duplicated: 0, missing: missing.length, wires: candidates.length };
270
283
  }
271
284
 
285
+ // A tail this short is what a blocked read is actually waiting on, and a
286
+ // second copy of it costs a few dozen kilobytes. A longer one is a piece
287
+ // still arriving normally, where duplicating would spend the shared link on
288
+ // bytes that are already coming — measured 2026-08-19, the tails a reader
289
+ // waits on are 2 to 14 blocks of 512.
290
+ if (missing.length > SHORT_TAIL_BLOCKS) {
291
+ return { duplicated: 0, missing: missing.length, wires: candidates.length };
292
+ }
293
+
272
294
  let duplicated = 0;
273
295
  // One block per wire: that is what the pipelines can usefully take at once,
274
296
  // and it needs no number of its own.
@@ -281,13 +303,14 @@ export function duplicateTailFor(torrent, pieceIndex) {
281
303
  // or it would free a THIRD wire's block as well.
282
304
  if (torrent._request(candidates[index], pieceIndex, false) === true) {
283
305
  duplicated += 1;
284
- continue;
285
306
  }
286
- // The wire's pipeline is full. The block stays in the piece's cancellation
287
- // stack and will be handed to whoever asks next, which is harmless it is
288
- // already in flight elsewhere but there is no point asking the remaining
289
- // wires, whose pipelines are no emptier.
290
- break;
307
+ // A refusal means THAT wire's pipeline is full, and says nothing about the
308
+ // next one's pipelines are per wire. This used to stop the whole pass on
309
+ // the first refusal, on the stated reasoning that the remaining wires were
310
+ // "no emptier", which is an assumption about other peers' queues that
311
+ // nothing here measures. The block whose reservation was freed stays in the
312
+ // piece's cancellation stack and is handed to whoever asks next, which is
313
+ // harmless: it is already in flight elsewhere.
291
314
  }
292
315
  return { duplicated, missing: missing.length, wires: candidates.length };
293
316
  }
@@ -950,11 +950,18 @@ export async function* readFragments({
950
950
  const result = askFastestWiresFor(torrent, pieceIndex);
951
951
  if (result.asked === 0) {
952
952
  tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
953
- // Nothing could be placed the ordinary way, which means every block
954
- // is spoken for. That is exactly when a second copy of the last
955
- // blocks is worth asking for.
956
- duplicated += duplicateTailFor(torrent, pieceIndex).duplicated;
957
953
  }
954
+ // Every attempt, not only the ones where nothing else could be placed.
955
+ // The ordinary steering asks for whatever blocks are still free; the
956
+ // read, meanwhile, ends when the LAST block arrives, and that block is
957
+ // reserved to one wire whether or not other blocks could be asked for.
958
+ // Field 2026-09-03: 46.3 s on one piece, ordinary requests placed on
959
+ // 54 of 87 attempts throughout, and a tail of 3 blocks of 512 held by
960
+ // wires at 51-99 KB/s to the end — while duplication, which ran only
961
+ // on the 33 attempts that placed nothing, managed 5 blocks in the
962
+ // whole wait. `duplicateTailFor` bounds itself by the tail's length,
963
+ // so a piece that is merely still arriving is left alone.
964
+ duplicated += duplicateTailFor(torrent, pieceIndex).duplicated;
958
965
  pushed = {
959
966
  asked: pushed.asked + result.asked,
960
967
  refusedWhileReserved:
@@ -204,6 +204,41 @@ export class WorkerTorrentPool {
204
204
  return Array.isArray(answer?.tracks) ? answer.tracks : [];
205
205
  }
206
206
 
207
+ /**
208
+ * Start fetching the region a viewer is about to resume at. Named in seconds
209
+ * here; the worker turns it into bytes, where the file's duration is readable.
210
+ *
211
+ * @param {object} torrent
212
+ * @param {number} fileIndex
213
+ * @param {number} positionSeconds
214
+ * @returns {Promise<boolean>}
215
+ */
216
+ async warmResumePosition(torrent, fileIndex, positionSeconds) {
217
+ const sourceKey = torrent?.sourceKey;
218
+ if (!sourceKey) {
219
+ return false;
220
+ }
221
+ const answer = await this.#client.warmResumePosition({ sourceKey, fileIndex, positionSeconds });
222
+ return answer?.started === true;
223
+ }
224
+
225
+ /**
226
+ * What one file declares about itself: format, duration, and where its own
227
+ * timeline begins.
228
+ *
229
+ * @param {object} torrent
230
+ * @param {number} fileIndex
231
+ * @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
232
+ */
233
+ async getContainerMediaInfo(torrent, fileIndex) {
234
+ const sourceKey = torrent?.sourceKey;
235
+ if (!sourceKey) {
236
+ return null;
237
+ }
238
+ const answer = await this.#client.getContainerMediaInfo({ sourceKey, fileIndex });
239
+ return answer?.info ?? null;
240
+ }
241
+
207
242
  /**
208
243
  * The audio tracks one file declares, in the order ffmpeg numbers them
209
244
  * `0:a:N`.
@@ -81,6 +81,18 @@ export const Command = {
81
81
  * file beside it, which is the same question about a different file.
82
82
  */
83
83
  CONTAINER_TRACKS: "container-tracks",
84
+ /**
85
+ * What a file declares about itself as a whole — format, duration, and where
86
+ * its own timeline begins. Read from the same header, by the same reader, as
87
+ * the track table above; the alternative was a second ffmpeg over the proxy's
88
+ * own HTTP reading the same bytes again.
89
+ */
90
+ CONTAINER_MEDIA_INFO: "container-media-info",
91
+ /**
92
+ * Start fetching the region a viewer is about to resume at, named in seconds
93
+ * and turned into bytes here, where the file's own duration can be read.
94
+ */
95
+ WARM_POSITION: "warm-position",
84
96
  /** Cues of one subtitle track, from the clusters already downloaded. */
85
97
  SUBTITLE_CUES: "subtitle-cues",
86
98
  /** Shut the client down, optionally deleting downloaded data. */
@@ -311,6 +311,21 @@ function nextSeq(state, trackNumber) {
311
311
  */
312
312
  export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
313
313
  const key = `${sourceKey}:${fileIndex}`;
314
+ // A torrent that cannot say which pieces it holds makes every range read as
315
+ // "not downloaded", so the walk reads nothing and returns an empty list —
316
+ // which is also what a file with no cues yet returns, and that is how this
317
+ // went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
318
+ // the browser served `WEBVTT` and nothing else). The stand-in the main thread
319
+ // holds is exactly such a torrent; only the thread that owns the object has
320
+ // the bitfield. Nothing here can repair that, so it says so instead.
321
+ if (!torrent?.bitfield || !(Number(torrent?.pieceLength) > 0)) {
322
+ logger.warn(
323
+ `subtitles: asked for cues of "${String(torrent?.name ?? sourceKey).slice(0, 40)}" ` +
324
+ "on a torrent that cannot say which pieces it holds — no cluster can be read here, " +
325
+ "and the answer would be an empty document indistinguishable from a file with no cues"
326
+ );
327
+ return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
328
+ }
314
329
  const plan = await planFor(torrent, fileIndex, key);
315
330
  const state = stateFor(key);
316
331
  const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
@@ -28,7 +28,12 @@ import { createSendStream } from "./channel.js";
28
28
  import { createFileClaims } from "./file-claims.js";
29
29
  import { readFragments, supplyFiguresFor } from "./piece-reader.js";
30
30
  import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
31
- import { CONTAINER_HEAD_BYTES, containerTracksOf } from "./container-tracks.js";
31
+ import {
32
+ CONTAINER_HEAD_BYTES,
33
+ containerMediaInfoOf,
34
+ containerTracksOf,
35
+ warmResumePosition
36
+ } from "./container-tracks.js";
32
37
  import { fillFileInBackground } from "./background-fill.js";
33
38
  import { Command, Event } from "./protocol.js";
34
39
  import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../memory-report.js";
@@ -418,6 +423,45 @@ async function runCommand(command, params, id) {
418
423
  };
419
424
  }
420
425
 
426
+ case Command.CONTAINER_MEDIA_INFO: {
427
+ const torrent = await requireTorrent(params.sourceKey);
428
+ return {
429
+ info: await containerMediaInfoOf(torrent, params.fileIndex, params.sourceKey, {
430
+ // Same reason as the track table above: the file this is asked about
431
+ // is often one nobody has played yet, so its head has to be fetched
432
+ // before there is anything to read.
433
+ prefetchEdges: () =>
434
+ pool.prefetchFileEdges(torrent, params.fileIndex, {
435
+ headBytes: CONTAINER_HEAD_BYTES,
436
+ tailBytes: 0,
437
+ timeoutMs: 60_000
438
+ })
439
+ })
440
+ };
441
+ }
442
+
443
+ case Command.WARM_POSITION: {
444
+ const torrent = await requireTorrent(params.sourceKey);
445
+ return {
446
+ started: await warmResumePosition(
447
+ torrent,
448
+ params.fileIndex,
449
+ params.sourceKey,
450
+ params.positionSeconds,
451
+ {
452
+ prefetchEdges: () =>
453
+ pool.prefetchFileEdges(torrent, params.fileIndex, {
454
+ headBytes: CONTAINER_HEAD_BYTES,
455
+ tailBytes: 0,
456
+ timeoutMs: 60_000
457
+ }),
458
+ fetchRegion: (start, bytes) =>
459
+ pool.prefetchFileRegion(torrent, params.fileIndex, start, bytes)
460
+ }
461
+ )
462
+ };
463
+ }
464
+
421
465
  case Command.SUBTITLE_CUES: {
422
466
  const torrent = await requireTorrent(params.sourceKey);
423
467
  const held = await cuesHeldFor(torrent, params.fileIndex, params.sourceKey, params.trackNumber);