@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.
- package/CHANGELOG.md +11 -0
- package/docs/container-architecture.md +66 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +19 -0
- package/routes/stream/get.js +18 -1
- package/server.js +25 -1
- package/services/container/AviContainer.js +36 -0
- package/services/container/Container.js +41 -0
- package/services/container/MatroskaContainer.js +186 -1
- package/services/container/Mp4Container.js +158 -29
- package/services/container-index/ebml-reader.js +30 -0
- package/services/hls-session-manager.js +167 -37
- package/services/orchestrators/ContainerOrchestrator.js +22 -0
- package/services/torrent-pool.js +46 -0
- package/services/torrent-worker/client.js +21 -0
- package/services/torrent-worker/container-tracks.js +134 -0
- package/services/torrent-worker/fastest-wires.js +29 -6
- package/services/torrent-worker/piece-reader.js +11 -4
- package/services/torrent-worker/pool-adapter.js +35 -0
- package/services/torrent-worker/protocol.js +12 -0
- package/services/torrent-worker/worker.js +45 -1
- package/test/container-media-info.test.js +228 -0
- package/test/resume-warm.test.js +39 -0
- package/test/tail-duplication.test.js +48 -4
|
@@ -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
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
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 {
|
|
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
|
+
});
|