@torrent-tv/proxy 2.72.2 → 2.73.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.
@@ -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. */
@@ -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);
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @file What a file declares about ITSELF — format, duration, and where its own
3
+ * timeline begins — read from its header by the container layer.
4
+ *
5
+ * These fixtures are built byte by byte rather than produced by ffmpeg, on
6
+ * purpose: a test that runs a real encoder measures the machine it runs on, and
7
+ * two such tests in this suite have failed four times in one day for exactly
8
+ * that reason (roadmap item 54). The numbers here are checked against ffmpeg
9
+ * ONCE, by hand, and the result is recorded rather than re-measured on every
10
+ * run — 2026-09-03, a Matroska file offset by 0.130435 s: ffmpeg reported
11
+ * `Duration: 00:00:02.13, start: 0.130000` and this reader answered
12
+ * `durationSeconds 2.131, startTimeSeconds 0.13`, which is the same number at
13
+ * the precision each prints. The same file as MP4: `start: 0.000000` from
14
+ * ffmpeg, 0 from this reader.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import test from "node:test";
19
+
20
+ import { MatroskaContainer } from "../services/container/MatroskaContainer.js";
21
+ import { Mp4Container } from "../services/container/Mp4Container.js";
22
+ import { AviContainer } from "../services/container/AviContainer.js";
23
+
24
+ /**
25
+ * An EBML element: its id bytes, a four-byte size, then the payload.
26
+ *
27
+ * @param {number[]} idBytes
28
+ * @param {Buffer} payload
29
+ * @returns {Buffer}
30
+ */
31
+ function ebml(idBytes, payload) {
32
+ const size = Buffer.alloc(4);
33
+ // Four-byte size form: `0001xxxx` in the leading byte marks the width.
34
+ size.writeUInt32BE(payload.length);
35
+ size[0] |= 0x10;
36
+ return Buffer.concat([Buffer.from(idBytes), size, payload]);
37
+ }
38
+
39
+ /** @param {number} value @param {number} bytes @returns {Buffer} */
40
+ function uint(value, bytes) {
41
+ const out = Buffer.alloc(bytes);
42
+ out.writeUIntBE(value, 0, bytes);
43
+ return out;
44
+ }
45
+
46
+ /** @param {number} value @returns {Buffer} */
47
+ function float64(value) {
48
+ const out = Buffer.alloc(8);
49
+ out.writeDoubleBE(value);
50
+ return out;
51
+ }
52
+
53
+ const ID_EBML_HEADER = [0x1a, 0x45, 0xdf, 0xa3];
54
+ const ID_SEGMENT = [0x18, 0x53, 0x80, 0x67];
55
+ const ID_SEEK_HEAD = [0x11, 0x4d, 0x9b, 0x74];
56
+ const ID_SEEK = [0x4d, 0xbb];
57
+ const ID_SEEK_ID = [0x53, 0xab];
58
+ const ID_SEEK_POSITION = [0x53, 0xac];
59
+ const ID_INFO = [0x15, 0x49, 0xa9, 0x66];
60
+ const ID_TIMESTAMP_SCALE = [0x2a, 0xd7, 0xb1];
61
+ const ID_DURATION = [0x44, 0x89];
62
+ const ID_CLUSTER = [0x1f, 0x43, 0xb6, 0x75];
63
+ const ID_TIMESTAMP = [0xe7];
64
+ const ID_VOID = [0xec];
65
+
66
+ /**
67
+ * A reader over a buffer, in the shape the container layer takes.
68
+ *
69
+ * @param {Buffer} bytes
70
+ * @returns {(start: number, end: number) => Promise<Buffer>}
71
+ */
72
+ function readerOver(bytes) {
73
+ return async (start, end) => bytes.subarray(start, Math.min(end + 1, bytes.length));
74
+ }
75
+
76
+ test("a Matroska file states its duration in ticks and its start in the first cluster", async () => {
77
+ const info = ebml(ID_INFO, Buffer.concat([
78
+ ebml(ID_TIMESTAMP_SCALE, uint(1_000_000, 3)),
79
+ // 2131 ticks of a millisecond each.
80
+ ebml(ID_DURATION, float64(2131))
81
+ ]));
82
+ const cluster = ebml(ID_CLUSTER, ebml(ID_TIMESTAMP, uint(130, 1)));
83
+ const file = Buffer.concat([
84
+ ebml(ID_EBML_HEADER, Buffer.alloc(4)),
85
+ ebml(ID_SEGMENT, Buffer.concat([info, cluster]))
86
+ ]);
87
+
88
+ const container = new MatroskaContainer({ readRange: readerOver(file), fileSize: file.length });
89
+ const read = await container.readMediaInfo();
90
+
91
+ assert.equal(read.format, "matroska");
92
+ assert.ok(Math.abs(read.durationSeconds - 2.131) < 1e-9, `duration was ${read.durationSeconds}`);
93
+ assert.ok(Math.abs(read.startTimeSeconds - 0.13) < 1e-9, `start was ${read.startTimeSeconds}`);
94
+ });
95
+
96
+ test("a cluster past the head window is found through the SeekHead", async () => {
97
+ // Everything before the cluster is padded past the 64 KB the head read covers,
98
+ // which is the case this second path exists for: a file whose Tracks element
99
+ // is large enough to push the first cluster out of reach.
100
+ const info = ebml(ID_INFO, ebml(ID_TIMESTAMP_SCALE, uint(1_000_000, 3)));
101
+ const padding = ebml(ID_VOID, Buffer.alloc(70 * 1024));
102
+ const cluster = ebml(ID_CLUSTER, ebml(ID_TIMESTAMP, uint(2500, 2)));
103
+ // The SeekHead is written first, so its own length is known before the
104
+ // position it names can be computed — build it with a placeholder, measure,
105
+ // then write the real position.
106
+ const seekHeadFor = (position) => ebml(ID_SEEK_HEAD, ebml(ID_SEEK, Buffer.concat([
107
+ ebml(ID_SEEK_ID, Buffer.from(ID_CLUSTER)),
108
+ ebml(ID_SEEK_POSITION, uint(position, 4))
109
+ ])));
110
+ const seekHeadLength = seekHeadFor(0).length;
111
+ const clusterPosition = seekHeadLength + info.length + padding.length;
112
+ const segmentPayload = Buffer.concat([seekHeadFor(clusterPosition), info, padding, cluster]);
113
+ const file = Buffer.concat([
114
+ ebml(ID_EBML_HEADER, Buffer.alloc(4)),
115
+ ebml(ID_SEGMENT, segmentPayload)
116
+ ]);
117
+
118
+ const container = new MatroskaContainer({ readRange: readerOver(file), fileSize: file.length });
119
+ const read = await container.readMediaInfo();
120
+
121
+ assert.ok(Math.abs(read.startTimeSeconds - 2.5) < 1e-9, `start was ${read.startTimeSeconds}`);
122
+ });
123
+
124
+ test("a Matroska file that declares no duration says so, rather than saying zero", async () => {
125
+ const cluster = ebml(ID_CLUSTER, ebml(ID_TIMESTAMP, uint(0, 1)));
126
+ const file = Buffer.concat([
127
+ ebml(ID_EBML_HEADER, Buffer.alloc(4)),
128
+ ebml(ID_SEGMENT, cluster)
129
+ ]);
130
+
131
+ const container = new MatroskaContainer({ readRange: readerOver(file), fileSize: file.length });
132
+ const read = await container.readMediaInfo();
133
+
134
+ assert.equal(read.durationSeconds, null);
135
+ assert.equal(read.startTimeSeconds, 0);
136
+ });
137
+
138
+ /**
139
+ * An ISO/IEC 14496-12 box.
140
+ *
141
+ * @param {string} type
142
+ * @param {Buffer} payload
143
+ * @returns {Buffer}
144
+ */
145
+ function box(type, payload) {
146
+ const header = Buffer.alloc(8);
147
+ header.writeUInt32BE(payload.length + 8);
148
+ header.write(type, 4, "latin1");
149
+ return Buffer.concat([header, payload]);
150
+ }
151
+
152
+ test("an MP4 states its duration in mvhd and its start in an empty edit", async () => {
153
+ const mvhd = box("mvhd", Buffer.concat([
154
+ Buffer.alloc(4), // version 0 + flags
155
+ Buffer.alloc(8), // creation, modification
156
+ uint(1000, 4), // timescale: ticks per second
157
+ uint(2000, 4), // duration: two seconds
158
+ Buffer.alloc(80)
159
+ ]));
160
+ const elst = box("elst", Buffer.concat([
161
+ Buffer.alloc(4), // version 0 + flags
162
+ uint(1, 4), // one entry
163
+ uint(130, 4), // segment_duration: 0.130 s at the movie timescale
164
+ Buffer.from([0xff, 0xff, 0xff, 0xff]), // media_time -1: an EMPTY edit
165
+ uint(0x00010000, 4) // media_rate 1.0
166
+ ]));
167
+ const trak = box("trak", box("edts", elst));
168
+ const file = Buffer.concat([
169
+ box("ftyp", Buffer.from("isom", "latin1")),
170
+ box("moov", Buffer.concat([mvhd, trak]))
171
+ ]);
172
+
173
+ const container = new Mp4Container({ readRange: readerOver(file), fileSize: file.length });
174
+ const read = await container.readMediaInfo();
175
+
176
+ assert.equal(read.format, "mp4");
177
+ assert.ok(Math.abs(read.durationSeconds - 2) < 1e-9, `duration was ${read.durationSeconds}`);
178
+ assert.ok(Math.abs(read.startTimeSeconds - 0.13) < 1e-9, `start was ${read.startTimeSeconds}`);
179
+ });
180
+
181
+ test("an MP4 with no edit list begins at zero, and that is an answer", async () => {
182
+ const mvhd = box("mvhd", Buffer.concat([
183
+ Buffer.alloc(4),
184
+ Buffer.alloc(8),
185
+ uint(600, 4),
186
+ uint(1200, 4),
187
+ Buffer.alloc(80)
188
+ ]));
189
+ const file = Buffer.concat([
190
+ box("ftyp", Buffer.from("isom", "latin1")),
191
+ box("moov", mvhd)
192
+ ]);
193
+
194
+ const container = new Mp4Container({ readRange: readerOver(file), fileSize: file.length });
195
+ const read = await container.readMediaInfo();
196
+
197
+ assert.ok(Math.abs(read.durationSeconds - 2) < 1e-9, `duration was ${read.durationSeconds}`);
198
+ assert.equal(read.startTimeSeconds, 0);
199
+ });
200
+
201
+ test("an AVI states its length as microseconds per frame times the frame count", async () => {
202
+ const avih = Buffer.concat([
203
+ Buffer.from("avih", "latin1"),
204
+ uint(56, 4),
205
+ Buffer.from(new Uint8Array(new Uint32Array([
206
+ 40_000, // dwMicroSecPerFrame: 25 fps
207
+ 0, 0, 0,
208
+ 50 // dwTotalFrames: two seconds of them
209
+ ]).buffer)),
210
+ Buffer.alloc(36)
211
+ ]);
212
+ const file = Buffer.concat([
213
+ Buffer.from("RIFF", "latin1"),
214
+ uint(0, 4),
215
+ Buffer.from("AVI ", "latin1"),
216
+ Buffer.from("LIST", "latin1"),
217
+ uint(avih.length + 4, 4),
218
+ Buffer.from("hdrl", "latin1"),
219
+ avih
220
+ ]);
221
+
222
+ const container = new AviContainer({ readRange: readerOver(file), fileSize: file.length });
223
+ const read = await container.readMediaInfo();
224
+
225
+ assert.equal(read.format, "avi");
226
+ assert.ok(Math.abs(read.durationSeconds - 2) < 1e-9, `duration was ${read.durationSeconds}`);
227
+ assert.equal(read.startTimeSeconds, 0);
228
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @file Where a viewer's resume position falls in the file, in bytes.
3
+ *
4
+ * The warm-up fetches a file's two edges, because that is what the codec probe
5
+ * reads. The region the VIEWER will resume at was asked for by nobody until the
6
+ * encoder opened its input — field 2026-09-03, 53 s after the Retry button on a
7
+ * cold torrent, and the piece it then needed took another 46.3 s to arrive.
8
+ * Turning the position into an offset is the only arithmetic in that path, so it
9
+ * is the only part with anything to get wrong.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import test from "node:test";
14
+
15
+ import { resumeByteOffset } from "../services/torrent-worker/container-tracks.js";
16
+
17
+ test("a position halfway through a film is halfway through its file", () => {
18
+ assert.equal(resumeByteOffset(1000, 100, 50), 500);
19
+ });
20
+
21
+ test("the field case lands where the encoder went looking", () => {
22
+ // The measured session: 171.338 s into a 23:41 episode of 541 MB, and the
23
+ // read that blocked was at 63 MB.
24
+ const at = resumeByteOffset(541 * 1024 * 1024, 23 * 60 + 41, 171.338);
25
+ const megabytes = at / (1024 * 1024);
26
+ assert.ok(megabytes > 60 && megabytes < 68, `landed at ${megabytes.toFixed(1)}MB`);
27
+ });
28
+
29
+ test("a position past the end reads the end, rather than past it", () => {
30
+ const length = 1000;
31
+ const at = resumeByteOffset(length, 100, 10_000);
32
+ assert.equal(at, length - 1);
33
+ });
34
+
35
+ test("nothing is known, nothing is guessed", () => {
36
+ assert.equal(resumeByteOffset(0, 100, 50), 0, "no file length");
37
+ assert.equal(resumeByteOffset(1000, 0, 50), 0, "no duration — the container did not declare one");
38
+ assert.equal(resumeByteOffset(1000, 100, 0), 0, "the viewer is at the beginning, where the edges already are");
39
+ });