@torrent-tv/proxy 2.9.44 → 2.9.46
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 +8 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +51 -11
- package/services/hwaccel.js +21 -14
- package/services/torrent-pool.js +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.46
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
|
|
5
|
+
## 2.9.45
|
|
6
|
+
|
|
7
|
+
- **New**: HLS transcode output switched from MPEG-TS (`.ts`) to **fMP4/CMAF** (`.m4s` segments + a shared `init.mp4`). Codec parameter sets (SPS/PPS) now live once in the init segment (referenced by `#EXT-X-MAP`) instead of being repeated in every segment. Benefits: (1) hardware encoders that do not repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce independently-usable segments (on `.ts` the segments after the first lacked SPS/PPS → "non-existing PPS", which is why v4l2m2m was rejected); (2) lower container overhead. The synthetic VOD playlist now emits `#EXT-X-VERSION:7` + `#EXT-X-MAP`; each seek-restart run rewrites `init.mp4`, so `getFileStream` caches and serves the FIRST init for the whole session — it is codec-config-only and position-independent (verified: a single init cleanly decodes segments produced by a later seek-restart run). Raised v4l2m2m `-num_capture_buffers` to 32 (the default 4 deadlocks / drops frames on the CM4). **Verified**: server-side clean decode of the synthetic playlist across seek-restart runs; end-to-end playback **and seek** in hls.js 1.6.16. **Still needs**: verification on native iOS HLS (Safari fMP4) before relying on it. NOTE: v4l2m2m still emits a residual no-picture access unit that the strict startup test rejects, so it continues to fall back to software for now (no regression); fMP4 removes the SPS/PPS blocker — the remaining quirk is separate.
|
|
8
|
+
|
|
1
9
|
## 2.9.44
|
|
2
10
|
|
|
3
11
|
- **Fix**: Roll back to WebTorrent **2.8.5** (pinned) — 3.x introduced two regressions that broke downloading. (1) `torrent.downloaded`/`file.downloaded`/`file.progress` throw on a `deselect`-ed null piece (worked around in 2.9.43). (2) Worse: the internal piece picker itself throws `Cannot read properties of null (reading 'reserve'/'missing')` when it tries to request a block from a piece our seek prioritization (`prioritizeByteRange` `deselect`) removed — download freezes dead after a seek (field-observed: file stuck at ~51%, `down=0`, picker crashing every second). 2.8.5 is the known-good version: `select`/`deselect`/`critical` and the byte getters all work (verified — add, multi-file download, and the full deselect+critical seek pattern run with zero crashes on 2.8.5). Also pinned **`uint8-util` 2.2.6**: 2.8.5's own range is `^2.2.5`, which *allows* the incompatible 2.3.x that a fresh global install pulled (the original `arr2hex` crash), so the transitive version must be forced back — webtorrent dedupes to 2.2.6 while sub-deps that need 2.3.x keep their own nested copy. The 2.9.43 null-safe getter helpers are now redundant (2.8.5 getters never throw) but left in as harmless defensive code.
|
package/package.json
CHANGED
|
@@ -31,7 +31,12 @@ import {
|
|
|
31
31
|
} from "./ffmpeg-banner.js";
|
|
32
32
|
|
|
33
33
|
const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
34
|
-
|
|
34
|
+
// fMP4 (CMAF) segments: SPS/PPS live once in the init segment, so every media
|
|
35
|
+
// segment is small and hardware encoders that do not repeat parameter sets
|
|
36
|
+
// (e.g. v4l2m2m) still produce independently-usable segments. The init segment
|
|
37
|
+
// is referenced by `#EXT-X-MAP` and fetched once by the player.
|
|
38
|
+
const SEGMENT_INIT_FILE_NAME = "init.mp4";
|
|
39
|
+
const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.m4s$/;
|
|
35
40
|
const CLEANUP_INTERVAL_MS = 30_000;
|
|
36
41
|
const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
37
42
|
// How many segments ahead of the current encode head a missing-segment request
|
|
@@ -203,18 +208,22 @@ function isSafeSessionId(value) {
|
|
|
203
208
|
* @returns {boolean}
|
|
204
209
|
*/
|
|
205
210
|
function isSafeFileName(fileName) {
|
|
206
|
-
return
|
|
211
|
+
return (
|
|
212
|
+
fileName === PLAYLIST_FILE_NAME ||
|
|
213
|
+
fileName === SEGMENT_INIT_FILE_NAME ||
|
|
214
|
+
SEGMENT_FILE_NAME_PATTERN.test(fileName)
|
|
215
|
+
);
|
|
207
216
|
}
|
|
208
217
|
|
|
209
218
|
/**
|
|
210
219
|
* Extract the zero-based segment index from a segment file name.
|
|
211
220
|
* Returns -1 when the name is not a valid segment file.
|
|
212
221
|
*
|
|
213
|
-
* @param {string} fileName - e.g. "segment-00012.
|
|
222
|
+
* @param {string} fileName - e.g. "segment-00012.m4s"
|
|
214
223
|
* @returns {number}
|
|
215
224
|
*/
|
|
216
225
|
function segmentIndexFromName(fileName) {
|
|
217
|
-
const match = /^segment-(\d{5})\.
|
|
226
|
+
const match = /^segment-(\d{5})\.m4s$/.exec(fileName);
|
|
218
227
|
if (!match) {
|
|
219
228
|
return -1;
|
|
220
229
|
}
|
|
@@ -1000,16 +1009,20 @@ export class HlsSessionManager {
|
|
|
1000
1009
|
}
|
|
1001
1010
|
const lines = [
|
|
1002
1011
|
"#EXTM3U",
|
|
1003
|
-
|
|
1012
|
+
// Version 7: required for fMP4 media segments + `#EXT-X-MAP`.
|
|
1013
|
+
"#EXT-X-VERSION:7",
|
|
1004
1014
|
`#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`,
|
|
1005
1015
|
"#EXT-X-MEDIA-SEQUENCE:0",
|
|
1006
1016
|
"#EXT-X-PLAYLIST-TYPE:VOD",
|
|
1007
|
-
"#EXT-X-INDEPENDENT-SEGMENTS"
|
|
1017
|
+
"#EXT-X-INDEPENDENT-SEGMENTS",
|
|
1018
|
+
// The fMP4 init segment (codec config / SPS/PPS). Fetched once; applies to
|
|
1019
|
+
// every media segment below.
|
|
1020
|
+
`#EXT-X-MAP:URI="${SEGMENT_INIT_FILE_NAME}"`
|
|
1008
1021
|
];
|
|
1009
1022
|
for (let index = 0; index < count; index += 1) {
|
|
1010
1023
|
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
1011
1024
|
lines.push(`#EXTINF:${duration.toFixed(6)},`);
|
|
1012
|
-
lines.push(`segment-${String(index).padStart(5, "0")}.
|
|
1025
|
+
lines.push(`segment-${String(index).padStart(5, "0")}.m4s`);
|
|
1013
1026
|
}
|
|
1014
1027
|
lines.push("#EXT-X-ENDLIST");
|
|
1015
1028
|
return `${lines.join("\n")}\n`;
|
|
@@ -1148,7 +1161,7 @@ export class HlsSessionManager {
|
|
|
1148
1161
|
}
|
|
1149
1162
|
const indices = [];
|
|
1150
1163
|
for (const name of names) {
|
|
1151
|
-
const match = /^segment-(\d{5})\.
|
|
1164
|
+
const match = /^segment-(\d{5})\.m4s$/.exec(name);
|
|
1152
1165
|
if (match) {
|
|
1153
1166
|
indices.push(parseInt(match[1], 10));
|
|
1154
1167
|
}
|
|
@@ -1161,7 +1174,7 @@ export class HlsSessionManager {
|
|
|
1161
1174
|
let bytes = 0;
|
|
1162
1175
|
try {
|
|
1163
1176
|
for (const index of completed) {
|
|
1164
|
-
const st = await stat(path.join(session.dirPath, `segment-${String(index).padStart(5, "0")}.
|
|
1177
|
+
const st = await stat(path.join(session.dirPath, `segment-${String(index).padStart(5, "0")}.m4s`));
|
|
1165
1178
|
bytes += st.size;
|
|
1166
1179
|
}
|
|
1167
1180
|
} catch {
|
|
@@ -1466,10 +1479,18 @@ export class HlsSessionManager {
|
|
|
1466
1479
|
"0",
|
|
1467
1480
|
"-hls_flags",
|
|
1468
1481
|
"independent_segments+temp_file",
|
|
1482
|
+
// fMP4 (CMAF) segments: codec config goes once into the init segment,
|
|
1483
|
+
// referenced by `#EXT-X-MAP`. Each seek-restart run rewrites init.mp4, but
|
|
1484
|
+
// it is codec-config only (position-independent), so getFileStream caches
|
|
1485
|
+
// and serves the first one for the whole session.
|
|
1486
|
+
"-hls_segment_type",
|
|
1487
|
+
"fmp4",
|
|
1488
|
+
"-hls_fmp4_init_filename",
|
|
1489
|
+
SEGMENT_INIT_FILE_NAME,
|
|
1469
1490
|
"-start_number",
|
|
1470
1491
|
String(safeIndex),
|
|
1471
1492
|
"-hls_segment_filename",
|
|
1472
|
-
"segment-%05d.
|
|
1493
|
+
"segment-%05d.m4s",
|
|
1473
1494
|
// ffmpeg writes its own playlist here; we ignore it and serve the
|
|
1474
1495
|
// synthetic VOD playlist instead (see getFileStream).
|
|
1475
1496
|
PLAYLIST_FILE_NAME
|
|
@@ -1779,6 +1800,25 @@ export class HlsSessionManager {
|
|
|
1779
1800
|
};
|
|
1780
1801
|
}
|
|
1781
1802
|
|
|
1803
|
+
// The fMP4 init segment (referenced by #EXT-X-MAP). It is codec-config only
|
|
1804
|
+
// and position-independent, but each seek-restart run REWRITES init.mp4, so
|
|
1805
|
+
// cache the FIRST one and always serve that — otherwise the init the player
|
|
1806
|
+
// fetched could differ from a later run's, breaking playback after a seek.
|
|
1807
|
+
if (fileName === SEGMENT_INIT_FILE_NAME) {
|
|
1808
|
+
if (session.initBytes) {
|
|
1809
|
+
return { kind: "file", stream: Readable.from([session.initBytes]), contentType: "video/mp4", isPlaylist: false };
|
|
1810
|
+
}
|
|
1811
|
+
try {
|
|
1812
|
+
const bytes = await readFile(path.join(session.dirPath, SEGMENT_INIT_FILE_NAME));
|
|
1813
|
+
session.initBytes = bytes;
|
|
1814
|
+
return { kind: "file", stream: Readable.from([bytes]), contentType: "video/mp4", isPlaylist: false };
|
|
1815
|
+
} catch {
|
|
1816
|
+
// Not produced yet — the encode run started at session creation writes
|
|
1817
|
+
// it early; the caller long-polls until it appears.
|
|
1818
|
+
return { kind: "warming-up" };
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1782
1822
|
const filePath = path.join(session.dirPath, fileName);
|
|
1783
1823
|
try {
|
|
1784
1824
|
await access(filePath);
|
|
@@ -1799,7 +1839,7 @@ export class HlsSessionManager {
|
|
|
1799
1839
|
contentType:
|
|
1800
1840
|
fileName === PLAYLIST_FILE_NAME
|
|
1801
1841
|
? "application/vnd.apple.mpegurl"
|
|
1802
|
-
: "video/
|
|
1842
|
+
: "video/mp4",
|
|
1803
1843
|
isPlaylist: fileName === PLAYLIST_FILE_NAME
|
|
1804
1844
|
};
|
|
1805
1845
|
} catch (_error) {
|
package/services/hwaccel.js
CHANGED
|
@@ -303,6 +303,10 @@ function v4l2m2mDescriptor() {
|
|
|
303
303
|
"-vf",
|
|
304
304
|
`scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps},format=yuv420p`,
|
|
305
305
|
"-c:v", "h264_v4l2m2m",
|
|
306
|
+
// More capture buffers than the default 4 — the default deadlocks /
|
|
307
|
+
// drops frames on the CM4 encoder ("All capture buffers returned to
|
|
308
|
+
// userspace").
|
|
309
|
+
"-num_capture_buffers", "32",
|
|
306
310
|
"-b:v", "3M",
|
|
307
311
|
"-g", String(outFps * segmentDurationSec),
|
|
308
312
|
...keyFrameArgs(segmentDurationSec)
|
|
@@ -438,7 +442,7 @@ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
|
|
|
438
442
|
encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
|
|
439
443
|
break;
|
|
440
444
|
case "v4l2m2m":
|
|
441
|
-
encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
|
|
445
|
+
encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-num_capture_buffers", "32", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
|
|
442
446
|
break;
|
|
443
447
|
default:
|
|
444
448
|
encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
|
|
@@ -450,7 +454,10 @@ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
|
|
|
450
454
|
"-hls_time", String(segmentDurationSec),
|
|
451
455
|
"-hls_list_size", "0",
|
|
452
456
|
"-hls_flags", "independent_segments",
|
|
453
|
-
|
|
457
|
+
// fMP4 (CMAF) — matches the runtime pipeline (hls-session-manager).
|
|
458
|
+
"-hls_segment_type", "fmp4",
|
|
459
|
+
"-hls_fmp4_init_filename", "init.mp4",
|
|
460
|
+
"-hls_segment_filename", path.join(outDir, "seg-%03d.m4s"),
|
|
454
461
|
path.join(outDir, "index.m3u8")
|
|
455
462
|
];
|
|
456
463
|
return [...pre, ...source, ...encode, ...hlsOut];
|
|
@@ -469,24 +476,24 @@ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
|
|
|
469
476
|
async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
|
|
470
477
|
let files;
|
|
471
478
|
try {
|
|
472
|
-
files = readdirSync(outDir).filter((n) => /^seg-\d+\.
|
|
479
|
+
files = readdirSync(outDir).filter((n) => /^seg-\d+\.m4s$/.test(n));
|
|
473
480
|
} catch {
|
|
474
481
|
return false;
|
|
475
482
|
}
|
|
476
483
|
if (files.length < 2) {
|
|
477
484
|
return false;
|
|
478
485
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
return
|
|
486
|
+
// fMP4: parameter sets (SPS/PPS) live in init.mp4, not in each segment.
|
|
487
|
+
// Decode the whole playlist (ffmpeg's own, which references init.mp4 via
|
|
488
|
+
// #EXT-X-MAP), so every segment is exercised together with the init. Any
|
|
489
|
+
// corrupt / non-conformant segment (e.g. some V4L2 M2M builds emit a stray
|
|
490
|
+
// no-picture access unit) surfaces as a decode error here.
|
|
491
|
+
const result = await runFfmpeg(
|
|
492
|
+
ffmpegBin,
|
|
493
|
+
["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, "index.m3u8"), "-f", "null", "-"],
|
|
494
|
+
12000
|
|
495
|
+
);
|
|
496
|
+
return result.code === 0 && result.stderr.trim().length === 0;
|
|
490
497
|
}
|
|
491
498
|
|
|
492
499
|
/**
|
package/services/torrent-pool.js
CHANGED
|
@@ -295,6 +295,16 @@ export class TorrentPool {
|
|
|
295
295
|
*/
|
|
296
296
|
#lastAccess = new Map();
|
|
297
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Last byte offset each active reader is streaming from, keyed by torrent then
|
|
300
|
+
* fileIndex. Set by prioritizeByteRange on every /stream range request; read by
|
|
301
|
+
* getFileStats to report how much of the window ahead of the read head is still
|
|
302
|
+
* to download — the "amount left to resume" shown while buffering.
|
|
303
|
+
*
|
|
304
|
+
* @type {Map<import("webtorrent").Torrent, Map<number, number>>}
|
|
305
|
+
*/
|
|
306
|
+
#readPositionByTorrent = new Map();
|
|
307
|
+
|
|
298
308
|
/** Global disk cap in bytes (0 = disabled). */
|
|
299
309
|
#maxDiskBytes = 0;
|
|
300
310
|
|
|
@@ -700,6 +710,7 @@ export class TorrentPool {
|
|
|
700
710
|
}
|
|
701
711
|
this.fileUsageByTorrent.delete(torrent);
|
|
702
712
|
this.#lastAccess.delete(torrent);
|
|
713
|
+
this.#readPositionByTorrent.delete(torrent);
|
|
703
714
|
const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
|
|
704
715
|
try {
|
|
705
716
|
torrent.destroy({ destroyStore: true }, () => {
|
|
@@ -743,6 +754,14 @@ export class TorrentPool {
|
|
|
743
754
|
|
|
744
755
|
const header = this.#getHeaderRangeProgress(torrent, file);
|
|
745
756
|
|
|
757
|
+
// Bytes still to download in the window ahead of where this file is being
|
|
758
|
+
// read — "how much left to resume". Null until a read position is known.
|
|
759
|
+
const readPositions = this.#readPositionByTorrent.get(torrent);
|
|
760
|
+
const readByteStart = readPositions ? readPositions.get(fileIndex) : undefined;
|
|
761
|
+
const resume = typeof readByteStart === "number"
|
|
762
|
+
? this.#getResumeWindowProgress(torrent, file, readByteStart)
|
|
763
|
+
: null;
|
|
764
|
+
|
|
746
765
|
// Null-safe downloaded/progress (webtorrent's own getters throw on a
|
|
747
766
|
// deselected null piece — see fileDownloadedBytes).
|
|
748
767
|
const fileLength = typeof file.length === "number" ? file.length : 0;
|
|
@@ -753,6 +772,9 @@ export class TorrentPool {
|
|
|
753
772
|
fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
|
|
754
773
|
fileDownloaded,
|
|
755
774
|
fileLength,
|
|
775
|
+
// Resume window (ahead of the read head): bytes needed vs downloaded.
|
|
776
|
+
resumeNeededBytes: resume ? resume.totalBytes : null,
|
|
777
|
+
resumeDownloadedBytes: resume ? resume.downloadedBytes : null,
|
|
756
778
|
// Phase-1 progress: how much of the header/index region (the bytes the
|
|
757
779
|
// codec probe needs before transcoding can start) is downloaded. Counted
|
|
758
780
|
// by whole pieces from the torrent bitfield, so it advances coarsely
|
|
@@ -808,6 +830,45 @@ export class TorrentPool {
|
|
|
808
830
|
return { totalBytes, downloadedBytes };
|
|
809
831
|
}
|
|
810
832
|
|
|
833
|
+
/**
|
|
834
|
+
* Count, by whole torrent pieces, how many bytes of the window AHEAD of the
|
|
835
|
+
* current read position are downloaded — i.e. how much is still to download
|
|
836
|
+
* before playback can continue from that point. Mirrors
|
|
837
|
+
* {@link #getHeaderRangeProgress}, for the moving read head.
|
|
838
|
+
*
|
|
839
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
840
|
+
* @param {import("webtorrent").TorrentFile} file
|
|
841
|
+
* @param {number} readByteStart - Byte offset within the file the reader is at.
|
|
842
|
+
* @returns {{ totalBytes: number, downloadedBytes: number } | null}
|
|
843
|
+
*/
|
|
844
|
+
#getResumeWindowProgress(torrent, file, readByteStart) {
|
|
845
|
+
const pieceLength = Number(torrent?.pieceLength);
|
|
846
|
+
const bitfield = torrent?.bitfield;
|
|
847
|
+
const fileLength = Number(file?.length);
|
|
848
|
+
if (
|
|
849
|
+
!Number.isFinite(pieceLength) || pieceLength <= 0 ||
|
|
850
|
+
!bitfield || typeof bitfield.get !== "function" ||
|
|
851
|
+
!Number.isFinite(fileLength) || fileLength <= 0
|
|
852
|
+
) {
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
|
|
856
|
+
const windowStart = Math.max(0, Math.min(Number(readByteStart) || 0, fileLength - 1));
|
|
857
|
+
const windowEnd = Math.min(fileLength - 1, windowStart + PRIORITY_WINDOW_BYTES - 1);
|
|
858
|
+
const firstPiece = Math.floor((fileOffset + windowStart) / pieceLength);
|
|
859
|
+
const lastPiece = Math.floor((fileOffset + windowEnd) / pieceLength);
|
|
860
|
+
// Byte-accurate: count the PARTIAL progress of in-progress pieces (not whole
|
|
861
|
+
// pieces), so "amount left" moves smoothly instead of jumping by a whole
|
|
862
|
+
// piece (8 MB here) at a time.
|
|
863
|
+
let totalBytes = 0;
|
|
864
|
+
let downloadedBytes = 0;
|
|
865
|
+
for (let piece = firstPiece; piece <= lastPiece; piece += 1) {
|
|
866
|
+
totalBytes += pieceLength;
|
|
867
|
+
downloadedBytes += pieceDownloadedBytes(torrent, piece);
|
|
868
|
+
}
|
|
869
|
+
return { totalBytes, downloadedBytes };
|
|
870
|
+
}
|
|
871
|
+
|
|
811
872
|
/**
|
|
812
873
|
* Pre-fetch the leading and trailing bytes of a torrent file so that
|
|
813
874
|
* WebTorrent prioritises the pieces that contain file headers and footers.
|
|
@@ -967,6 +1028,16 @@ export class TorrentPool {
|
|
|
967
1028
|
const fileEndPiece = Math.floor((fileOffset + fileLength - 1) / pieceLength);
|
|
968
1029
|
|
|
969
1030
|
const safeStart = Math.max(0, Number(byteStart) || 0);
|
|
1031
|
+
|
|
1032
|
+
// Remember where this file is being read from, so getFileStats can report the
|
|
1033
|
+
// download progress of the window ahead of the read head (resume amount).
|
|
1034
|
+
let readPositions = this.#readPositionByTorrent.get(torrent);
|
|
1035
|
+
if (!readPositions) {
|
|
1036
|
+
readPositions = new Map();
|
|
1037
|
+
this.#readPositionByTorrent.set(torrent, readPositions);
|
|
1038
|
+
}
|
|
1039
|
+
readPositions.set(fileIndex, safeStart);
|
|
1040
|
+
|
|
970
1041
|
const absStart = fileOffset + safeStart;
|
|
971
1042
|
const playheadPiece = Math.floor(absStart / pieceLength);
|
|
972
1043
|
const absWindowEnd = Math.min(
|