@torrent-tv/proxy 2.9.46 → 2.9.48
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 +1 -1
- package/routes/api/sources/stats/get.js +8 -1
- package/services/hls-session-manager.js +103 -12
- package/services/torrent-pool.js +21 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.9.48
|
|
2
|
+
|
|
3
|
+
- **Fix**: The "bytes still needed to resume" figure shown while buffering could jump UP mid-poll even though nothing regressed, which read as confusing/broken. Root cause: the resume-window progress (`resumeNeededBytes`/`resumeDownloadedBytes`) was always computed against the LIVE read position, which slides forward as the file is read/transcoded further — so when the window moved past an already-downloaded piece into a fresh, never-touched one, "bytes needed" jumped up (a moving reference frame, not a real setback). `getFileStats` now accepts an optional `resumeAnchorByteStart` and always returns the byte offset the window was computed against; the browser client captures that offset on the FIRST poll of a buffering episode and sends it back on every subsequent poll of the SAME episode, so the window stays pinned to a fixed target and the figure only ever decreases as real download progress happens. Verified: with the anchor pinned, repeated polls report the same "needed" while the live read position moves with no new data, and a real download of a piece inside the frozen window correctly decreases it.
|
|
4
|
+
- **Fix**: A rapid sequence of seeks could leave playback permanently stuck — field-diagnosed from a live session (5 seek-restarts in 16 seconds), showing `failed to rename file segment-NNNNN.m4s.tmp` and a zombie ffmpeg still writing a `.tmp` file ~30 seconds after being "killed" by two later restarts, even after the session had already been released. Root cause: `#startEncodeRun` sent `SIGTERM` to the previous ffmpeg process and immediately spawned the replacement into the SAME session directory without waiting for it to actually exit. `ChildProcess.killed` only means a signal was sent, not that the process died — ffmpeg's own blocking read of our torrent-backed `/stream` input can defer signal handling for a long time while starved, so on a rapid sequence of seeks multiple ffmpeg processes ended up alive concurrently, fighting over CPU and racing each other's file writes in the same directory; none of them would finish a segment in time, which is what "stuck at seek" looks like to the viewer. Fixed by awaiting the previous process's exit (escalating from `SIGTERM` to `SIGKILL` if it does not exit within a grace period, reusing the `waitForChildExit` helper `disposeSession` already used correctly) before spawning the replacement. A new per-session generation counter (`encodeRunGeneration`) lets a restart that was superseded by an even newer seek while it was waiting abort instead of also spawning a process — verified with a standalone race simulation: 5 overlapping restarts against a slow-to-die previous process spawn exactly 1 process, matching the LATEST requested target.
|
|
5
|
+
|
|
6
|
+
## 2.9.47
|
|
7
|
+
|
|
8
|
+
- **Fix**: Playback could get permanently stuck (hls.js endlessly re-fetching the manifest and the first segment, buffer never advancing) even though the transcode itself was encoding fine, running ahead of realtime. Root cause: ffmpeg creates the fMP4 `init.mp4` file before it finishes writing the codec-header boxes into it (unlike segments, its write is not gated behind an atomic rename), so a request could race a moment where the file exists but is still empty. That empty read was then cached forever as the session's init segment — a zero-length `Buffer` is still a truthy object, so the `if (session.initBytes)` cache guard treated it as "already resolved" and kept serving the empty file for the rest of the session, which hls.js can never initialize a SourceBuffer from. Fixed by treating a zero-byte read as not-yet-ready (keeps the caller's existing long-poll retrying) instead of caching it as final.
|
|
9
|
+
|
|
1
10
|
## 2.9.46
|
|
2
11
|
|
|
3
12
|
- **New**: `getFileStats` now reports `resumeNeededBytes` / `resumeDownloadedBytes` — the bytes still to download in the 16 MB window ahead of the file's current read position (tracked per file by `prioritizeByteRange`, cleared on torrent removal), counted byte-accurately including partial pieces. Lets the browser show how much is left to download and the time to resume while buffering.
|
package/package.json
CHANGED
|
@@ -40,7 +40,14 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
40
40
|
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
41
41
|
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
42
42
|
|
|
43
|
-
|
|
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
|
+
const stats = torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
|
|
44
51
|
|
|
45
52
|
// Diagnostic: surface the real swarm state per poll so a cold-start download
|
|
46
53
|
// stall (0 peers / header not advancing → playback-plan blocks on the codec
|
|
@@ -48,6 +48,15 @@ const MAX_LOOKAHEAD_SEGMENTS = 8;
|
|
|
48
48
|
// succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
|
|
49
49
|
// between positions, restarting endlessly and producing nothing.
|
|
50
50
|
const RESTART_COOLDOWN_MS = 4_000;
|
|
51
|
+
// Encoder stall watchdog. A running ffmpeg emits `-progress` output on stdout
|
|
52
|
+
// continuously while it encodes; when it hangs mid-file (alive, but producing
|
|
53
|
+
// no output and no stderr — a deadlock, e.g. a stalled input read), that output
|
|
54
|
+
// stops and `progress.updatedAt` freezes. If a segment INSIDE the look-ahead
|
|
55
|
+
// window is being demanded but progress has not advanced for this long, the
|
|
56
|
+
// encoder is wedged (observed: the segment 503s forever). Treat it like a seek
|
|
57
|
+
// and restart ffmpeg at the demanded segment. Conservative — a slow-but-moving
|
|
58
|
+
// encode keeps advancing `updatedAt`, so this only fires on a true freeze.
|
|
59
|
+
const ENCODER_STALL_MS = 12_000;
|
|
51
60
|
// Seek debounce. A far (out-of-window) segment request is a server-side seek.
|
|
52
61
|
// Rather than restart ffmpeg on the first one, wait a short quiet period:
|
|
53
62
|
// further far requests re-arm it and update the target to the latest index, so
|
|
@@ -59,6 +68,10 @@ const SEEK_SETTLE_MS = 1_200;
|
|
|
59
68
|
// Hard cap on the total settle wait, measured from the first far request of a
|
|
60
69
|
// burst, so a still-moving scrubber cannot delay a genuine seek forever.
|
|
61
70
|
const SEEK_SETTLE_MAX_MS = 2_500;
|
|
71
|
+
// Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
|
|
72
|
+
// escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
|
|
73
|
+
// the same session directory. See #startEncodeRun.
|
|
74
|
+
const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
|
|
62
75
|
// Idle TTL: a session is disposed this long after the last segment/playlist
|
|
63
76
|
// access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
|
|
64
77
|
// turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
|
|
@@ -152,6 +165,20 @@ function waitForChildExit(child, timeoutMs = 2_000) {
|
|
|
152
165
|
});
|
|
153
166
|
}
|
|
154
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Whether a child process has genuinely exited. `ChildProcess.killed` only
|
|
170
|
+
* means `.kill()` was called — the process can stay alive well after that
|
|
171
|
+
* (blocked in I/O, ignoring/delaying the signal). `exitCode`/`signalCode` are
|
|
172
|
+
* only set once the `exit` event has actually fired, so this is the reliable
|
|
173
|
+
* check before treating a directory/file as free for a new process to use.
|
|
174
|
+
*
|
|
175
|
+
* @param {import("node:child_process").ChildProcess} child
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function hasChildExited(child) {
|
|
179
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
180
|
+
}
|
|
181
|
+
|
|
155
182
|
/**
|
|
156
183
|
* Convert a bind-all host address to the loopback address so that
|
|
157
184
|
* the HLS input URL is always reachable from the same machine.
|
|
@@ -591,6 +618,9 @@ function normalizeLogFileName(fileName, fileIndex) {
|
|
|
591
618
|
* @property {string} lastError
|
|
592
619
|
* @property {Set<string>} consumers - Consumer IDs currently using this session.
|
|
593
620
|
* @property {object} progress - Live progress metrics updated from ffmpeg stdout.
|
|
621
|
+
* @property {number} encodeRunGeneration - Bumped on every #startEncodeRun call;
|
|
622
|
+
* lets a call that awaited the previous ffmpeg's exit detect it was superseded
|
|
623
|
+
* by a newer restart request and abort instead of spawning a second process.
|
|
594
624
|
*/
|
|
595
625
|
|
|
596
626
|
/**
|
|
@@ -869,6 +899,7 @@ export class HlsSessionManager {
|
|
|
869
899
|
startedAt: Date.now(),
|
|
870
900
|
lastAccessedAt: Date.now(),
|
|
871
901
|
ffmpeg: null,
|
|
902
|
+
encodeRunGeneration: 0,
|
|
872
903
|
lastError: "",
|
|
873
904
|
// Cold-start timing: entry timestamp + a once-guard so the first servable
|
|
874
905
|
// segment logs its latency exactly once.
|
|
@@ -970,7 +1001,7 @@ export class HlsSessionManager {
|
|
|
970
1001
|
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
971
1002
|
);
|
|
972
1003
|
|
|
973
|
-
this.#startEncodeRun(session, 0);
|
|
1004
|
+
await this.#startEncodeRun(session, 0);
|
|
974
1005
|
|
|
975
1006
|
try {
|
|
976
1007
|
await this.waitUntilReady(session);
|
|
@@ -1220,7 +1251,7 @@ export class HlsSessionManager {
|
|
|
1220
1251
|
if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
|
|
1221
1252
|
return false; // let the previous action settle
|
|
1222
1253
|
}
|
|
1223
|
-
this.#applyBudgetDownshift(
|
|
1254
|
+
await this.#applyBudgetDownshift(
|
|
1224
1255
|
session,
|
|
1225
1256
|
`link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps buffer=${report.bufferedAheadSec.toFixed(1)}s`,
|
|
1226
1257
|
"link"
|
|
@@ -1292,7 +1323,7 @@ export class HlsSessionManager {
|
|
|
1292
1323
|
session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
|
|
1293
1324
|
continue;
|
|
1294
1325
|
}
|
|
1295
|
-
this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
|
|
1326
|
+
await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
|
|
1296
1327
|
}
|
|
1297
1328
|
}
|
|
1298
1329
|
|
|
@@ -1340,9 +1371,9 @@ export class HlsSessionManager {
|
|
|
1340
1371
|
* @param {HlsSession} session
|
|
1341
1372
|
* @param {string} reasonText - Measurement summary for the log line.
|
|
1342
1373
|
* @param {"cpu" | "unknown" | "link"} bound
|
|
1343
|
-
* @returns {void}
|
|
1374
|
+
* @returns {Promise<void>}
|
|
1344
1375
|
*/
|
|
1345
|
-
#applyBudgetDownshift(session, reasonText, bound) {
|
|
1376
|
+
async #applyBudgetDownshift(session, reasonText, bound) {
|
|
1346
1377
|
const nextIndex = session.budgetRungIndex + 1;
|
|
1347
1378
|
const rung = session.budgetLadder[nextIndex];
|
|
1348
1379
|
if (!rung) {
|
|
@@ -1371,22 +1402,66 @@ export class HlsSessionManager {
|
|
|
1371
1402
|
`(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
|
|
1372
1403
|
`restart at segment #${currentSeg} "${session.fileName}"`
|
|
1373
1404
|
);
|
|
1374
|
-
this.#startEncodeRun(session, currentSeg);
|
|
1405
|
+
await this.#startEncodeRun(session, currentSeg);
|
|
1375
1406
|
}
|
|
1376
1407
|
|
|
1377
1408
|
/**
|
|
1378
1409
|
* (Re)start the ffmpeg encode run beginning at segment `startIndex`.
|
|
1379
1410
|
*
|
|
1380
|
-
* Any ffmpeg process currently running for this session is terminated
|
|
1411
|
+
* Any ffmpeg process currently running for this session is terminated FIRST
|
|
1412
|
+
* AND ITS EXIT IS AWAITED before the replacement is spawned into the same
|
|
1413
|
+
* directory. This closes a real incident: a fire-and-forget SIGTERM does not
|
|
1414
|
+
* mean the process is dead — `ChildProcess.killed` reflects only that a
|
|
1415
|
+
* signal was sent, not that the process exited (ffmpeg's own blocking read of
|
|
1416
|
+
* our torrent-backed `/stream` input can defer signal handling for a long
|
|
1417
|
+
* time while starved). On a rapid sequence of seeks this left multiple
|
|
1418
|
+
* ffmpeg processes alive concurrently, all writing into the SAME session
|
|
1419
|
+
* directory — observed as `failed to rename file segment-NNNNN.m4s.tmp`
|
|
1420
|
+
* (a dying process racing a fresh one) and a zombie process still writing a
|
|
1421
|
+
* `.tmp` file ~30s after being "killed" by two LATER restarts, even after the
|
|
1422
|
+
* session had already been released. Multiple ffmpeg processes fighting over
|
|
1423
|
+
* CPU and the same files on a weak host is what a seek could get "stuck" on.
|
|
1424
|
+
*
|
|
1425
|
+
* Because this now awaits, a NEWER restart request can arrive while an OLDER
|
|
1426
|
+
* one is still waiting for the previous process to die. `encodeRunGeneration`
|
|
1427
|
+
* resolves that: each call captures its own generation number, and after the
|
|
1428
|
+
* await, a call whose generation was superseded aborts without spawning —
|
|
1429
|
+
* only the LATEST requested target ever actually starts a process.
|
|
1430
|
+
*
|
|
1381
1431
|
* Segment files are named with a global index (`-start_number`) so they
|
|
1382
1432
|
* always line up with the synthetic VOD playlist regardless of where
|
|
1383
1433
|
* encoding started — this is what makes server-side seeking work.
|
|
1384
1434
|
*
|
|
1385
1435
|
* @param {HlsSession} session
|
|
1386
1436
|
* @param {number} startIndex
|
|
1387
|
-
* @returns {void}
|
|
1437
|
+
* @returns {Promise<void>}
|
|
1388
1438
|
*/
|
|
1389
|
-
#startEncodeRun(session, startIndex) {
|
|
1439
|
+
async #startEncodeRun(session, startIndex) {
|
|
1440
|
+
const generation = ++session.encodeRunGeneration;
|
|
1441
|
+
const previousFfmpeg = session.ffmpeg;
|
|
1442
|
+
if (previousFfmpeg && !hasChildExited(previousFfmpeg)) {
|
|
1443
|
+
try {
|
|
1444
|
+
previousFfmpeg.kill("SIGTERM");
|
|
1445
|
+
} catch {
|
|
1446
|
+
// Best effort.
|
|
1447
|
+
}
|
|
1448
|
+
await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
|
|
1449
|
+
if (!hasChildExited(previousFfmpeg)) {
|
|
1450
|
+
try {
|
|
1451
|
+
previousFfmpeg.kill("SIGKILL");
|
|
1452
|
+
} catch {
|
|
1453
|
+
// Best effort.
|
|
1454
|
+
}
|
|
1455
|
+
await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// A newer restart (or disposal) won the race while we were waiting for the
|
|
1459
|
+
// old process to die — it either already spawned its own replacement or
|
|
1460
|
+
// there is nothing left to start. Do not also spawn from this stale call.
|
|
1461
|
+
if (session.encodeRunGeneration !== generation || session.state === "disposed") {
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1390
1465
|
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
1391
1466
|
// 0-based output time of this segment, from the boundary table (uniform for
|
|
1392
1467
|
// re-encode, real keyframe for copy).
|
|
@@ -1631,7 +1706,7 @@ export class HlsSessionManager {
|
|
|
1631
1706
|
`transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
|
|
1632
1707
|
`(${session.lastError}); falling back to software libx264 and restarting`
|
|
1633
1708
|
);
|
|
1634
|
-
this.#startEncodeRun(session, session.encodeStartIndex);
|
|
1709
|
+
void this.#startEncodeRun(session, session.encodeStartIndex);
|
|
1635
1710
|
return;
|
|
1636
1711
|
}
|
|
1637
1712
|
session.state = "failed";
|
|
@@ -1713,7 +1788,7 @@ export class HlsSessionManager {
|
|
|
1713
1788
|
session.seekTarget = null;
|
|
1714
1789
|
session.seekFirstFarAt = 0;
|
|
1715
1790
|
logger.info(`transcode ${session.id} seek settle → restart at segment #${target}`);
|
|
1716
|
-
this.#startEncodeRun(session, target);
|
|
1791
|
+
void this.#startEncodeRun(session, target);
|
|
1717
1792
|
}
|
|
1718
1793
|
|
|
1719
1794
|
/**
|
|
@@ -1804,12 +1879,28 @@ export class HlsSessionManager {
|
|
|
1804
1879
|
// and position-independent, but each seek-restart run REWRITES init.mp4, so
|
|
1805
1880
|
// cache the FIRST one and always serve that — otherwise the init the player
|
|
1806
1881
|
// fetched could differ from a later run's, breaking playback after a seek.
|
|
1882
|
+
//
|
|
1883
|
+
// ffmpeg creates init.mp4 before it has finished writing the fMP4 header
|
|
1884
|
+
// boxes into it (unlike segments, its write is not gated behind an atomic
|
|
1885
|
+
// rename), so a read can race a moment where the file EXISTS but is still
|
|
1886
|
+
// EMPTY. Root cause of a real incident: that empty read used to be cached
|
|
1887
|
+
// as `session.initBytes` — a zero-length Buffer is still a truthy object,
|
|
1888
|
+
// so `if (session.initBytes)` treated it as "already resolved" and served
|
|
1889
|
+
// the empty file for the rest of the session's life, permanently breaking
|
|
1890
|
+
// playback (hls.js can never initialize its SourceBuffer from an empty
|
|
1891
|
+
// init segment) while the transcode itself kept encoding normally. Guard
|
|
1892
|
+
// on non-empty content on both the cache check and the fresh read, so an
|
|
1893
|
+
// empty read is treated as not-yet-ready and the caller's long-poll keeps
|
|
1894
|
+
// retrying until ffmpeg has actually written the header.
|
|
1807
1895
|
if (fileName === SEGMENT_INIT_FILE_NAME) {
|
|
1808
|
-
if (session.initBytes) {
|
|
1896
|
+
if (session.initBytes && session.initBytes.length > 0) {
|
|
1809
1897
|
return { kind: "file", stream: Readable.from([session.initBytes]), contentType: "video/mp4", isPlaylist: false };
|
|
1810
1898
|
}
|
|
1811
1899
|
try {
|
|
1812
1900
|
const bytes = await readFile(path.join(session.dirPath, SEGMENT_INIT_FILE_NAME));
|
|
1901
|
+
if (bytes.length === 0) {
|
|
1902
|
+
return { kind: "warming-up" };
|
|
1903
|
+
}
|
|
1813
1904
|
session.initBytes = bytes;
|
|
1814
1905
|
return { kind: "file", stream: Readable.from([bytes]), contentType: "video/mp4", isPlaylist: false };
|
|
1815
1906
|
} catch {
|
package/services/torrent-pool.js
CHANGED
|
@@ -727,6 +727,16 @@ export class TorrentPool {
|
|
|
727
727
|
*
|
|
728
728
|
* @param {import("webtorrent").Torrent} torrent
|
|
729
729
|
* @param {number | null} [fileIndex] - Zero-based file index, or null for torrent-level only.
|
|
730
|
+
* @param {{ resumeAnchorByteStart?: number | null }} [options] - `resumeAnchorByteStart`
|
|
731
|
+
* pins the resume window to a FIXED byte offset instead of the live (moving)
|
|
732
|
+
* read position. Without it, the window is anchored to wherever the file is
|
|
733
|
+
* CURRENTLY being read from — which slides forward as playback/encoding
|
|
734
|
+
* advances, so "bytes still needed" can jump up mid-poll even though nothing
|
|
735
|
+
* regressed (the window just moved past already-downloaded pieces into
|
|
736
|
+
* fresh ones). The caller should capture the returned `resumeAnchorByteStart`
|
|
737
|
+
* on the FIRST poll of a buffering episode and pass it back on subsequent
|
|
738
|
+
* polls of that SAME episode, so "bytes needed" counts down monotonically
|
|
739
|
+
* against a fixed target instead of chasing a moving one.
|
|
730
740
|
* @returns {{
|
|
731
741
|
* numPeers: number,
|
|
732
742
|
* downloadSpeed: number,
|
|
@@ -736,7 +746,7 @@ export class TorrentPool {
|
|
|
736
746
|
* fileLength: number | null
|
|
737
747
|
* }}
|
|
738
748
|
*/
|
|
739
|
-
getFileStats(torrent, fileIndex = null) {
|
|
749
|
+
getFileStats(torrent, fileIndex = null, options = {}) {
|
|
740
750
|
const numPeers = typeof torrent?.numPeers === "number" ? torrent.numPeers : 0;
|
|
741
751
|
const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
|
|
742
752
|
const uploadSpeed = typeof torrent?.uploadSpeed === "number" ? torrent.uploadSpeed : 0;
|
|
@@ -754,10 +764,14 @@ export class TorrentPool {
|
|
|
754
764
|
|
|
755
765
|
const header = this.#getHeaderRangeProgress(torrent, file);
|
|
756
766
|
|
|
757
|
-
// Bytes still to download in the window ahead of
|
|
758
|
-
//
|
|
767
|
+
// Bytes still to download in the window ahead of the anchor point — "how
|
|
768
|
+
// much left to resume". Null until a read position is known. The anchor is
|
|
769
|
+
// the caller-supplied FROZEN offset when given (see JSDoc above), otherwise
|
|
770
|
+
// the live (moving) read position tracked from /stream range requests.
|
|
759
771
|
const readPositions = this.#readPositionByTorrent.get(torrent);
|
|
760
|
-
const
|
|
772
|
+
const liveReadByteStart = readPositions ? readPositions.get(fileIndex) : undefined;
|
|
773
|
+
const requestedAnchor = options?.resumeAnchorByteStart;
|
|
774
|
+
const readByteStart = Number.isFinite(requestedAnchor) ? requestedAnchor : liveReadByteStart;
|
|
761
775
|
const resume = typeof readByteStart === "number"
|
|
762
776
|
? this.#getResumeWindowProgress(torrent, file, readByteStart)
|
|
763
777
|
: null;
|
|
@@ -772,9 +786,11 @@ export class TorrentPool {
|
|
|
772
786
|
fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
|
|
773
787
|
fileDownloaded,
|
|
774
788
|
fileLength,
|
|
775
|
-
// Resume window (ahead of the
|
|
789
|
+
// Resume window (ahead of the anchor): bytes needed vs downloaded, plus
|
|
790
|
+
// the anchor itself so the caller can pin it for the rest of one episode.
|
|
776
791
|
resumeNeededBytes: resume ? resume.totalBytes : null,
|
|
777
792
|
resumeDownloadedBytes: resume ? resume.downloadedBytes : null,
|
|
793
|
+
resumeAnchorByteStart: typeof readByteStart === "number" ? readByteStart : null,
|
|
778
794
|
// Phase-1 progress: how much of the header/index region (the bytes the
|
|
779
795
|
// codec probe needs before transcoding can start) is downloaded. Counted
|
|
780
796
|
// by whole pieces from the torrent bitfield, so it advances coarsely
|