@torrent-tv/proxy 2.9.105 → 2.9.107
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 +9 -0
- package/package.json +3 -2
- package/routes/api/sources/stats/get.js +82 -69
- package/services/playback-planner.js +34 -11
- package/test/plan-host-timings.test.js +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.9.107
|
|
2
|
+
|
|
3
|
+
- **Fix**: 2.9.106 could not produce a playback plan at all — `Failed to prepare playback plan: firstSegmentMs is not defined`. Moving the two host timings to be read when a plan is ANSWERED removed the two variables but left the object literal still naming them, on the path that builds a fresh plan. My own linter reports it in four seconds and I did not run it, which is the second time an undeclared name has reached a release; `npm publish` now runs it, so this class of error cannot leave the machine again. The test added with 2.9.106 did not catch it because it exercises the cached path only — the fresh-plan path needs a real probe.
|
|
4
|
+
|
|
5
|
+
## 2.9.106
|
|
6
|
+
|
|
7
|
+
- **Fix**: The two figures the browser needs to say how long until playback now reach it for the file that needs them most. Both are medians of sessions already finished on this host, and the plan read them at the moment it was BUILT and then cached the result — so the very first file opened after a restart got `null` for both and kept answering `null` for the life of the process, however many sessions ran afterwards. Measured 2026-08-05: a fresh proxy answered `null`, then created the session in 6 ms and produced the first segment in 21 479 ms. They are now read when the plan is ANSWERED, so a cached plan reports what the host currently knows. Covered by tests.
|
|
8
|
+
- **New**: When the stats route has nothing to report it says which thing is missing — the torrent handle, the file index, or neither. A source answered `peers=0 file=n/a header=n/a` for minutes on 2026-08-05 while that very torrent was announcing to trackers with hundreds of seeders, and the line could not tell those cases apart. That line is what the viewer's loading screen shows, so it has to be answerable from the log.
|
|
9
|
+
|
|
1
10
|
## 2.9.105
|
|
2
11
|
|
|
3
12
|
- **Fix**: A reader's claim on pieces is put back when WebTorrent drops it, which is what stopped a download dead for eleven minutes. A reader declares the window it needs as a selection and withdraws it when it ends; that claim turns out not to be durable — the library deletes a selection the moment every piece in it is present (`remove fully downloaded selection`). While the reader keeps moving this is invisible, because the next window is claimed at once. It is fatal when the reader STOPS: the encoder gets held back by the look-ahead cap, ffmpeg stops reading, the reader parks on a window that is fully downloaded, the selection disappears, and no code of ours can notice because the reader is parked inside a write. Measured 2026-08-05: the encoder was suspended at 22:44:51, the download hit zero at 22:45:05 and stayed there for eleven minutes with 150 peer connections open and the new diagnostic reading `0 selection(s) covering 0 piece(s), 0 being asked, 0 blocks in flight`; when the encoder was let go there was nothing ahead of it. Live reader windows are now re-asserted from the pool's own timer, using the set the piece store already keeps, and only where something is actually missing — re-claiming a satisfied window would only be deleted again on the next pass.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.107",
|
|
4
4
|
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"publishConfig": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"start": "node ./bin/cli.js",
|
|
18
18
|
"dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
|
|
19
19
|
"test": "node --test",
|
|
20
|
-
"lint": "biome lint ."
|
|
20
|
+
"lint": "biome lint .",
|
|
21
|
+
"prepublishOnly": "npm run lint"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@fastify/cors": "^11.2.0",
|
|
@@ -1,69 +1,82 @@
|
|
|
1
|
-
import { logger } from "../../../../utils/logger.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Return download statistics for a registered torrent source.
|
|
5
|
-
*
|
|
6
|
-
* Provides peer count, transfer speeds, and per-file download progress so
|
|
7
|
-
* that the browser client can display meaningful feedback while the proxy is
|
|
8
|
-
* pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
|
|
9
|
-
*
|
|
10
|
-
* GET /api/sources/:sourceKey/stats?fileIndex=N
|
|
11
|
-
*
|
|
12
|
-
* @param {import("fastify").FastifyRequest} req
|
|
13
|
-
* @param {import("fastify").FastifyReply} reply
|
|
14
|
-
* @param {{
|
|
15
|
-
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
16
|
-
* torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
|
|
17
|
-
* }} deps
|
|
18
|
-
* @returns {Promise<void>}
|
|
19
|
-
*/
|
|
20
|
-
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
21
|
-
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
22
|
-
if (!sourceKey) {
|
|
23
|
-
return reply.code(400).send({ error: "sourceKey is required." });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
27
|
-
if (!sourceRecord) {
|
|
28
|
-
return reply.code(404).send({ error: "Source key was not found." });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let torrent;
|
|
32
|
-
try {
|
|
33
|
-
// getTorrent resolves immediately when the torrent is already loaded.
|
|
34
|
-
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
35
|
-
} catch (error) {
|
|
36
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
-
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
41
|
-
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
42
|
-
|
|
43
|
-
// Optional: pin the resume window to a FIXED byte offset for the duration of
|
|
44
|
-
// one buffering episode (see getFileStats JSDoc) instead of the live, moving
|
|
45
|
-
// read position — otherwise "bytes needed" can jump up mid-poll as the window
|
|
46
|
-
// slides forward with playback/encoding progress.
|
|
47
|
-
const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
|
|
48
|
-
const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
|
|
49
|
-
|
|
50
|
-
// Awaited: with the torrent on its own thread this is a round trip, not a
|
|
51
|
-
// local lookup. Without the await the reply was the pending promise itself,
|
|
52
|
-
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
53
|
-
const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
|
|
54
|
-
|
|
55
|
-
// Diagnostic: surface the real swarm state per poll so a cold-start download
|
|
56
|
-
// stall (0 peers / header not advancing → playback-plan blocks on the codec
|
|
57
|
-
// probe → browser timeout) is visible in the proxy log.
|
|
58
|
-
const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
|
|
59
|
-
const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
|
|
60
|
-
const header =
|
|
61
|
-
stats.headerBytes != null
|
|
62
|
-
? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
|
|
63
|
-
: "n/a";
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
1
|
+
import { logger } from "../../../../utils/logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return download statistics for a registered torrent source.
|
|
5
|
+
*
|
|
6
|
+
* Provides peer count, transfer speeds, and per-file download progress so
|
|
7
|
+
* that the browser client can display meaningful feedback while the proxy is
|
|
8
|
+
* pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
|
|
9
|
+
*
|
|
10
|
+
* GET /api/sources/:sourceKey/stats?fileIndex=N
|
|
11
|
+
*
|
|
12
|
+
* @param {import("fastify").FastifyRequest} req
|
|
13
|
+
* @param {import("fastify").FastifyReply} reply
|
|
14
|
+
* @param {{
|
|
15
|
+
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
16
|
+
* torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
|
|
17
|
+
* }} deps
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
21
|
+
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
22
|
+
if (!sourceKey) {
|
|
23
|
+
return reply.code(400).send({ error: "sourceKey is required." });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
27
|
+
if (!sourceRecord) {
|
|
28
|
+
return reply.code(404).send({ error: "Source key was not found." });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let torrent;
|
|
32
|
+
try {
|
|
33
|
+
// getTorrent resolves immediately when the torrent is already loaded.
|
|
34
|
+
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
41
|
+
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
42
|
+
|
|
43
|
+
// Optional: pin the resume window to a FIXED byte offset for the duration of
|
|
44
|
+
// one buffering episode (see getFileStats JSDoc) instead of the live, moving
|
|
45
|
+
// read position — otherwise "bytes needed" can jump up mid-poll as the window
|
|
46
|
+
// slides forward with playback/encoding progress.
|
|
47
|
+
const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
|
|
48
|
+
const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
|
|
49
|
+
|
|
50
|
+
// Awaited: with the torrent on its own thread this is a round trip, not a
|
|
51
|
+
// local lookup. Without the await the reply was the pending promise itself,
|
|
52
|
+
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
53
|
+
const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
|
|
54
|
+
|
|
55
|
+
// Diagnostic: surface the real swarm state per poll so a cold-start download
|
|
56
|
+
// stall (0 peers / header not advancing → playback-plan blocks on the codec
|
|
57
|
+
// probe → browser timeout) is visible in the proxy log.
|
|
58
|
+
const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
|
|
59
|
+
const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
|
|
60
|
+
const header =
|
|
61
|
+
stats.headerBytes != null
|
|
62
|
+
? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
|
|
63
|
+
: "n/a";
|
|
64
|
+
// When the answer is empty, say WHICH thing is missing. Field 2026-08-05: a
|
|
65
|
+
// source reported `peers=0 file=n/a header=n/a` for minutes while that very
|
|
66
|
+
// torrent was announcing to trackers with hundreds of seeders — and the line
|
|
67
|
+
// above cannot tell apart "the torrent handle is not the live one", "the file
|
|
68
|
+
// index did not resolve" and "no file index was asked for". That is what the
|
|
69
|
+
// viewer's loading screen was showing at the time, so it has to be
|
|
70
|
+
// answerable from the log rather than by reasoning about it afterwards.
|
|
71
|
+
const emptyAnswer = stats.fileProgress == null || (stats.numPeers === 0 && torrent.done !== true);
|
|
72
|
+
const detail = emptyAnswer
|
|
73
|
+
? ` | infoHash=${String(torrent.infoHash).slice(0, 8)} files=${torrent.files?.length ?? "?"}` +
|
|
74
|
+
` askedFor=${fileIndex ?? "none"} resolved=${torrent.files?.[fileIndex ?? -1] ? "yes" : "no"}` +
|
|
75
|
+
` wires=${torrent.wires?.length ?? "?"} done=${torrent.done === true}`
|
|
76
|
+
: "";
|
|
77
|
+
logger.info(
|
|
78
|
+
`[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}${detail}`
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return reply.send(stats);
|
|
82
|
+
}
|
|
@@ -286,6 +286,31 @@ export function createPlaybackPlanner({
|
|
|
286
286
|
*/
|
|
287
287
|
const mediaInfoCache = new Map();
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Attach what this host currently measures itself taking to create a session
|
|
291
|
+
* and to produce a first segment.
|
|
292
|
+
*
|
|
293
|
+
* Read at RESPONSE time, deliberately. Both are medians of sessions that have
|
|
294
|
+
* already finished on this host, so at the moment a plan is BUILT the very
|
|
295
|
+
* first file opened after a restart has none and gets `null` — and the plan
|
|
296
|
+
* is then cached, so that file kept answering `null` for the life of the
|
|
297
|
+
* process however many sessions ran afterwards. Measured 2026-08-05: a fresh
|
|
298
|
+
* 2.9.103 answered `null` for both, then produced the session in 6 ms and the
|
|
299
|
+
* first segment in 21 479 ms. The figures existed; the plan could not carry
|
|
300
|
+
* them, and the browser's estimate fell back to its own guess in exactly the
|
|
301
|
+
* cold-start case the feature was built for.
|
|
302
|
+
*
|
|
303
|
+
* @param {PlaybackPlan} plan
|
|
304
|
+
* @returns {PlaybackPlan}
|
|
305
|
+
*/
|
|
306
|
+
function withHostTimings(plan) {
|
|
307
|
+
return {
|
|
308
|
+
...plan,
|
|
309
|
+
expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
|
|
310
|
+
expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
289
314
|
return {
|
|
290
315
|
/**
|
|
291
316
|
* Media info the planner already probed for this file, or `null`. Lets the
|
|
@@ -322,7 +347,7 @@ export function createPlaybackPlanner({
|
|
|
322
347
|
const cacheKey = `${sourceKey}:${fileIndex}`;
|
|
323
348
|
const cached = cache.get(cacheKey);
|
|
324
349
|
if (cached) {
|
|
325
|
-
return cached;
|
|
350
|
+
return withHostTimings(cached);
|
|
326
351
|
}
|
|
327
352
|
// Where the time before playback goes. `cold-start` already breaks down
|
|
328
353
|
// everything from the transcode-session request onwards, but the plan
|
|
@@ -365,7 +390,7 @@ export function createPlaybackPlanner({
|
|
|
365
390
|
subtitleTracks: []
|
|
366
391
|
};
|
|
367
392
|
cache.set(cacheKey, plan);
|
|
368
|
-
return plan;
|
|
393
|
+
return withHostTimings(plan);
|
|
369
394
|
}
|
|
370
395
|
|
|
371
396
|
// Pre-fetch file edges (head + tail), then probe — retrying while the
|
|
@@ -402,8 +427,6 @@ export function createPlaybackPlanner({
|
|
|
402
427
|
}
|
|
403
428
|
const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
|
|
404
429
|
const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
|
|
405
|
-
const firstSegmentMs = expectedFirstSegmentMs?.() ?? null;
|
|
406
|
-
const sessionCreateMs = expectedSessionCreateMs?.() ?? null;
|
|
407
430
|
logger.info(
|
|
408
431
|
`plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
|
|
409
432
|
`file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
|
|
@@ -430,11 +453,11 @@ export function createPlaybackPlanner({
|
|
|
430
453
|
// Full track inventory for the browser's audio/subtitle menus.
|
|
431
454
|
audioTracks: audioTracks ?? [],
|
|
432
455
|
subtitleTracks: subtitleTracks ?? [],
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
expectedSessionCreateMs:
|
|
456
|
+
// Both host timings are filled in by `withHostTimings` on the way out,
|
|
457
|
+
// never here: read at build time they would be frozen into the cached
|
|
458
|
+
// plan, which is the bug fixed in 2.9.106.
|
|
459
|
+
expectedFirstSegmentMs: null,
|
|
460
|
+
expectedSessionCreateMs: null
|
|
438
461
|
};
|
|
439
462
|
// Only cache a plan whose codecs were actually detected. An empty probe is
|
|
440
463
|
// a "header not downloaded yet" signal, not a valid result — caching it
|
|
@@ -463,9 +486,9 @@ export function createPlaybackPlanner({
|
|
|
463
486
|
timeoutMs: 60_000
|
|
464
487
|
})
|
|
465
488
|
.catch(() => {});
|
|
466
|
-
return plan;
|
|
489
|
+
return withHostTimings(plan);
|
|
467
490
|
}
|
|
468
|
-
return { ...plan, pending: true };
|
|
491
|
+
return withHostTimings({ ...plan, pending: true });
|
|
469
492
|
}
|
|
470
493
|
};
|
|
471
494
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The host's own timings must reach the browser for the file that needs
|
|
3
|
+
* them most.
|
|
4
|
+
*
|
|
5
|
+
* The plan carries two figures the browser cannot measure for itself: how long
|
|
6
|
+
* this host takes to create a session, and how long it takes to produce a first
|
|
7
|
+
* segment. Both are medians of sessions that have already finished here, so at
|
|
8
|
+
* the moment a plan is BUILT the very first file opened after a restart has
|
|
9
|
+
* neither — and the plan is cached, so that file kept answering `null` for the
|
|
10
|
+
* life of the process however many sessions ran afterwards. Measured
|
|
11
|
+
* 2026-08-05: a fresh proxy answered `null` for both, then created the session
|
|
12
|
+
* in 6 ms and produced the first segment in 21 479 ms.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { createPlaybackPlanner } from "../services/playback-planner.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A planner whose host timings can be changed between calls, over a source that
|
|
21
|
+
* needs no torrent: transcoding disabled short-circuits to a cached direct plan
|
|
22
|
+
* without probing anything.
|
|
23
|
+
*/
|
|
24
|
+
function plannerWithTimings() {
|
|
25
|
+
const timings = { create: null, first: null };
|
|
26
|
+
const planner = createPlaybackPlanner({
|
|
27
|
+
ffmpegBin: "ffmpeg",
|
|
28
|
+
localBaseUrl: "http://127.0.0.1:9090/stream",
|
|
29
|
+
sourceRegistry: { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:0" }) },
|
|
30
|
+
torrentPool: { getTorrent: async () => ({ files: [{ name: "a.mkv", length: 10 }] }) },
|
|
31
|
+
transcodeAudioEnabled: false,
|
|
32
|
+
expectedSessionCreateMs: () => timings.create,
|
|
33
|
+
expectedFirstSegmentMs: () => timings.first
|
|
34
|
+
});
|
|
35
|
+
return { planner, timings };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("a plan built before the host had measured anything still reports them later", async () => {
|
|
39
|
+
const { planner, timings } = plannerWithTimings();
|
|
40
|
+
|
|
41
|
+
const cold = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
|
|
42
|
+
assert.equal(cold.expectedSessionCreateMs, null, "nothing has finished yet, so there is nothing to report");
|
|
43
|
+
assert.equal(cold.expectedFirstSegmentMs, null);
|
|
44
|
+
|
|
45
|
+
// Sessions run; the host now knows what it costs.
|
|
46
|
+
timings.create = 6;
|
|
47
|
+
timings.first = 21_479;
|
|
48
|
+
|
|
49
|
+
const warm = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
|
|
50
|
+
assert.equal(
|
|
51
|
+
warm.expectedSessionCreateMs,
|
|
52
|
+
6,
|
|
53
|
+
"the cached plan withheld the figure the browser needed"
|
|
54
|
+
);
|
|
55
|
+
assert.equal(warm.expectedFirstSegmentMs, 21_479);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the figures follow the host as they change", async () => {
|
|
59
|
+
const { planner, timings } = plannerWithTimings();
|
|
60
|
+
timings.create = 100;
|
|
61
|
+
timings.first = 800;
|
|
62
|
+
const first = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
|
|
63
|
+
assert.equal(first.expectedFirstSegmentMs, 800);
|
|
64
|
+
|
|
65
|
+
timings.first = 7_000; // a re-encode session — an order of magnitude slower
|
|
66
|
+
const later = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
|
|
67
|
+
assert.equal(later.expectedFirstSegmentMs, 7_000, "the plan reported a stale figure");
|
|
68
|
+
});
|