@torrent-tv/proxy 2.9.86 → 2.9.88
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/routes/stream/get.js +3 -1
- package/services/segment-formats/mp4-boxes.js +33 -8
- package/services/torrent-pool.js +34 -1
- package/services/torrent-worker/client.js +9 -3
- package/services/torrent-worker/pool-adapter.js +9 -2
- package/services/torrent-worker/worker.js +3 -1
- package/test/fmp4-timestamps.test.js +140 -0
- package/test/piece-selection-offset.test.js +85 -0
- package/test/stream-route.test.js +26 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.88
|
|
2
|
+
|
|
3
|
+
- **Fix**: A seek no longer makes the swarm walk the file to get there. Two faults, both confirmed by running WebTorrent's own selection code on the numbers of a measured session (588 pieces, download at 38.4%, seek to 89.1%). First: a selection carries an `offset` — how many pieces from its start are already downloaded — and the picker scans from `from + offset`; `deselect` subtracts an interval and copies that offset into what survives, so demoting the pieces behind the playhead left `{523-587, offset 226}`, a selection whose scan begins at piece 749 of 587. The seek target ended up wanted by nobody. The range is now re-selected right after the demotion, which replaces the dead entry with a fresh one starting at the playhead. Second: a request with no byte range was reported as an ordinary read at offset 0, and ffmpeg opens its input with exactly such a request and abandons it as soon as it seeks — as do the keyframe index and the codec probe, four of them around every encoder restart. Each one re-selected the whole file from piece zero, undoing the seek; the picker then skipped what was on disk and downloaded forward from the first hole. Measured cost of the pair: a seek to 89.1% of a 4.7 GB film fetched **2.47 GB over 93 s** where one 8 MiB piece was needed. A range-less read now sets the read position only when nothing else has.
|
|
4
|
+
|
|
5
|
+
## 2.9.87
|
|
6
|
+
|
|
7
|
+
- **Fix**: fMP4 playback no longer stops after the first segment. A segment's position was being written into **every** fragment it contains, and the explicit-cut muxer puts several in one segment — `frag_keyframe` opens a fragment at each keyframe while a cut point comes only every few keyframes. Measured: a 6 s piece carries three fragments per track, at 0, 2 and 4 s of its own clock; all three were stamped with the segment's start, so they claimed the same decode time and the player rejected the segment. In the field (2.9.86, this session) that showed as segments 1 and 2 requested in an endless alternation, each served in tens of milliseconds with the transcode healthy at 12x, while the picture froze a few seconds in. The position is now applied as a shift: each track's first fragment sets the base and the rest keep their distance from it. With one fragment per track — what the `hls` muxer produces — a shift and a write are the same thing, so the other path is unchanged. Verified end to end on the addon host: four pieces cut, split, stamped and reassembled the way a player does, then probed — 600 frames over 24 s, decode timestamps rising by exactly 0.04 s across every segment join, no duplicates, clean decode.
|
|
8
|
+
|
|
1
9
|
## 2.9.86
|
|
2
10
|
|
|
3
11
|
- **Fix**: fMP4 playback starts again. The real reason ffmpeg exited before writing anything was the audio, not the file names: the MP4 muxer derives a copied AC-3 track's `dac3` box from the bitstream, so it cannot write `moov` until the first audio packet arrives, while our `empty_moov` demands it at header time — `Cannot write moov atom before AC3 packets. Set the delay_moov flag to fix this.`, captured in the field on a copied AC-3 source. `delay_moov` is now passed alongside it. The `hls` muxer sets that flag itself, which is why the fault appeared only once the muxing moved to the `segment` muxer in 2.9.84; MPEG-TS has no `moov` and was never affected. Verified in the addon container on an AC-3 source: without the flag the exact command the proxy runs fails, with it the segments are written, and the piece layout is unchanged (`ftyp moov moof mdat … mfra`), so the init split added in 2.9.84 still cuts in the same places — headers of consecutive pieces differ in four bytes, all inside `elst`, which the `tfdt` rewriting already overrides.
|
package/package.json
CHANGED
package/routes/stream/get.js
CHANGED
|
@@ -94,7 +94,9 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool
|
|
|
94
94
|
// byte offset) downloads first instead of waiting behind the sequential
|
|
95
95
|
// backlog — this is what caused ~15-18 s stalls when seeking into an
|
|
96
96
|
// undownloaded region.
|
|
97
|
-
torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0
|
|
97
|
+
torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0, undefined, {
|
|
98
|
+
wholeFileRead: range === null
|
|
99
|
+
});
|
|
98
100
|
|
|
99
101
|
const start = range ? range.start : 0;
|
|
100
102
|
const end = range ? range.end : file.length - 1;
|
|
@@ -113,6 +113,19 @@ export function readTrackTimescales(initSegment) {
|
|
|
113
113
|
* independently-addressable segment anyway: it carries its own position, so it
|
|
114
114
|
* is valid against any init for the same tracks.
|
|
115
115
|
*
|
|
116
|
+
* A SEGMENT MAY HOLD SEVERAL FRAGMENTS PER TRACK, so the segment's start is
|
|
117
|
+
* applied as a SHIFT, not as a value written into every `tfdt`. The muxer that
|
|
118
|
+
* takes explicit cut times uses `frag_keyframe`, which opens a fragment at each
|
|
119
|
+
* keyframe, while a cut point is only every few keyframes — measured on the
|
|
120
|
+
* field host: a 6 s piece carried three fragments per track, at 0, 2 and 4 s of
|
|
121
|
+
* its own clock. Writing the segment's start into all three made them claim the
|
|
122
|
+
* same decode time; the player rejected the segment and re-fetched it forever
|
|
123
|
+
* (field 2026-08-04: segments 1 and 2 alternating for minutes, each served in
|
|
124
|
+
* tens of milliseconds, transcode healthy at 12x). Each track's first fragment
|
|
125
|
+
* therefore defines the base and the rest keep their distance from it. With one
|
|
126
|
+
* fragment per track — what the `hls` muxer produces — a shift and a write are
|
|
127
|
+
* the same thing, so both paths are served by this.
|
|
128
|
+
*
|
|
116
129
|
* Mutates a copy; the caller's buffer is untouched.
|
|
117
130
|
*
|
|
118
131
|
* @param {Buffer} segment
|
|
@@ -128,6 +141,8 @@ export function stampSegmentStartTime(segment, startSeconds, trackTimescales) {
|
|
|
128
141
|
// `tfhd` carries the track id and always precedes the `tfdt` inside the same
|
|
129
142
|
// `traf`, so an ordered pass pairs each `tfdt` with its track's timescale.
|
|
130
143
|
let currentTrackId = null;
|
|
144
|
+
/** @type {Map<number, number>} trackId → decode time of that track's first fragment. */
|
|
145
|
+
const fragmentBase = new Map();
|
|
131
146
|
walkBoxes(stamped, (type, bodyStart, bodyEnd) => {
|
|
132
147
|
if (type === "tfhd") {
|
|
133
148
|
if (bodyStart + 8 <= bodyEnd) {
|
|
@@ -143,17 +158,27 @@ export function stampSegmentStartTime(segment, startSeconds, trackTimescales) {
|
|
|
143
158
|
return;
|
|
144
159
|
}
|
|
145
160
|
const version = stamped[bodyStart];
|
|
146
|
-
|
|
161
|
+
if (version === 1 ? bodyStart + 12 > bodyEnd : bodyStart + 8 > bodyEnd) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const existing =
|
|
165
|
+
version === 1
|
|
166
|
+
? Number(stamped.readBigUInt64BE(bodyStart + 4))
|
|
167
|
+
: stamped.readUInt32BE(bodyStart + 4);
|
|
168
|
+
if (!fragmentBase.has(currentTrackId)) {
|
|
169
|
+
fragmentBase.set(currentTrackId, existing);
|
|
170
|
+
}
|
|
171
|
+
// Distance from the track's first fragment in this segment. Never negative:
|
|
172
|
+
// decode times only move forward, and a malformed one must not drag a later
|
|
173
|
+
// fragment behind the segment's start.
|
|
174
|
+
const withinSegment = Math.max(0, existing - (fragmentBase.get(currentTrackId) ?? 0));
|
|
175
|
+
const value = Math.round(startSeconds * timescale) + withinSegment;
|
|
147
176
|
if (version === 1) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
} else if (bodyStart + 8 <= bodyEnd) {
|
|
177
|
+
stamped.writeBigUInt64BE(BigInt(value), bodyStart + 4);
|
|
178
|
+
} else if (value <= 0xffffffff) {
|
|
152
179
|
// A 32-bit field cannot express beyond ~2^32 ticks; leave it rather than
|
|
153
180
|
// write a wrapped value (the player would land somewhere arbitrary).
|
|
154
|
-
|
|
155
|
-
stamped.writeUInt32BE(value, bodyStart + 4);
|
|
156
|
-
}
|
|
181
|
+
stamped.writeUInt32BE(value, bodyStart + 4);
|
|
157
182
|
}
|
|
158
183
|
});
|
|
159
184
|
return stamped;
|
package/services/torrent-pool.js
CHANGED
|
@@ -1048,9 +1048,18 @@ export class TorrentPool {
|
|
|
1048
1048
|
* @param {number} fileIndex
|
|
1049
1049
|
* @param {number} byteStart - Start offset within the file.
|
|
1050
1050
|
* @param {number} [windowBytes] - Bytes ahead of `byteStart` to mark critical.
|
|
1051
|
+
* @param {{ wholeFileRead?: boolean }} [options] - `wholeFileRead` marks a
|
|
1052
|
+
* request that carried no byte range, i.e. one that merely opens the file at
|
|
1053
|
+
* 0 rather than asking to read from there. See the guard below.
|
|
1051
1054
|
* @returns {void}
|
|
1052
1055
|
*/
|
|
1053
|
-
prioritizeByteRange(
|
|
1056
|
+
prioritizeByteRange(
|
|
1057
|
+
torrent,
|
|
1058
|
+
fileIndex,
|
|
1059
|
+
byteStart,
|
|
1060
|
+
windowBytes = PRIORITY_WINDOW_BYTES,
|
|
1061
|
+
options = {}
|
|
1062
|
+
) {
|
|
1054
1063
|
if (!torrent || typeof torrent.critical !== "function" || !Array.isArray(torrent.files)) {
|
|
1055
1064
|
return;
|
|
1056
1065
|
}
|
|
@@ -1080,6 +1089,20 @@ export class TorrentPool {
|
|
|
1080
1089
|
this.#readPositionByTorrent.set(torrent, readPositions);
|
|
1081
1090
|
}
|
|
1082
1091
|
const previousStart = readPositions.get(fileIndex);
|
|
1092
|
+
|
|
1093
|
+
// A request with no byte range says nothing about where the viewer is. ffmpeg
|
|
1094
|
+
// opens its input with a plain GET and abandons it the moment it seeks, and
|
|
1095
|
+
// the keyframe index and the codec probe do the same — four such reads around
|
|
1096
|
+
// every encoder restart, each one arriving here as "position 0". Acting on
|
|
1097
|
+
// them undoes the seek that just happened: the whole file is re-selected from
|
|
1098
|
+
// piece 0, the picker skips the pieces already on disk and walks the swarm
|
|
1099
|
+
// forward from the first hole. Measured on a 4.7 GB film: a seek to 89.1%
|
|
1100
|
+
// downloaded 2.47 GB over 93 s before the segment could be served. So a
|
|
1101
|
+
// whole-file read only sets the position when nothing else has.
|
|
1102
|
+
if (options.wholeFileRead && previousStart !== undefined) {
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1083
1106
|
readPositions.set(fileIndex, safeStart);
|
|
1084
1107
|
|
|
1085
1108
|
// Log jumps only. Sequential reading calls this on every range request and
|
|
@@ -1133,6 +1156,16 @@ export class TorrentPool {
|
|
|
1133
1156
|
if (playheadPiece > fileStartPiece && typeof torrent.deselect === "function") {
|
|
1134
1157
|
try {
|
|
1135
1158
|
torrent.deselect(fileStartPiece, playheadPiece - 1);
|
|
1159
|
+
// `deselect` subtracts the interval and copies the selection's `offset`
|
|
1160
|
+
// — how many pieces from its start are already downloaded — into what
|
|
1161
|
+
// remains. The picker scans from `from + offset`, so the surviving
|
|
1162
|
+
// selection starts scanning far past its own end and can never yield a
|
|
1163
|
+
// piece: measured with the library's own `Selections`, deselecting
|
|
1164
|
+
// 0-522 from `{0-587, offset 226}` leaves `{523-587, offset 226}`,
|
|
1165
|
+
// i.e. a scan starting at piece 749 of 587. The seek target ends up
|
|
1166
|
+
// wanted by nobody. Re-selecting the same range replaces that dead
|
|
1167
|
+
// entry with a fresh one whose offset is 0.
|
|
1168
|
+
torrent.select(playheadPiece, fileEndPiece, 1);
|
|
1136
1169
|
this.#selectedFromPiece.get(torrent)?.set(fileIndex, playheadPiece);
|
|
1137
1170
|
} catch {
|
|
1138
1171
|
// Best effort — never break streaming because demotion failed.
|
|
@@ -198,11 +198,17 @@ export class TorrentWorkerClient {
|
|
|
198
198
|
/**
|
|
199
199
|
* Reorder piece selection around a read position (seek prioritisation).
|
|
200
200
|
*
|
|
201
|
-
* @param {{ sourceKey: string, fileIndex: number, byteStart: number, windowBytes?: number }} params
|
|
201
|
+
* @param {{ sourceKey: string, fileIndex: number, byteStart: number, windowBytes?: number, wholeFileRead?: boolean }} params
|
|
202
202
|
* @returns {Promise<void>}
|
|
203
203
|
*/
|
|
204
|
-
async prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes }) {
|
|
205
|
-
await this.#caller.call(Command.PRIORITIZE, {
|
|
204
|
+
async prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes, wholeFileRead }) {
|
|
205
|
+
await this.#caller.call(Command.PRIORITIZE, {
|
|
206
|
+
sourceKey,
|
|
207
|
+
fileIndex,
|
|
208
|
+
byteStart,
|
|
209
|
+
windowBytes,
|
|
210
|
+
wholeFileRead
|
|
211
|
+
});
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
/**
|
|
@@ -141,15 +141,22 @@ export class WorkerTorrentPool {
|
|
|
141
141
|
* @param {number} fileIndex
|
|
142
142
|
* @param {number} byteStart
|
|
143
143
|
* @param {number} [windowBytes]
|
|
144
|
+
* @param {{ wholeFileRead?: boolean }} [options]
|
|
144
145
|
* @returns {void}
|
|
145
146
|
*/
|
|
146
|
-
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
|
|
147
|
+
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes, options) {
|
|
147
148
|
const sourceKey = torrent?.sourceKey;
|
|
148
149
|
if (!sourceKey) {
|
|
149
150
|
return;
|
|
150
151
|
}
|
|
151
152
|
void this.#client
|
|
152
|
-
.prioritizeByteRange({
|
|
153
|
+
.prioritizeByteRange({
|
|
154
|
+
sourceKey,
|
|
155
|
+
fileIndex,
|
|
156
|
+
byteStart,
|
|
157
|
+
windowBytes,
|
|
158
|
+
wholeFileRead: options?.wholeFileRead === true
|
|
159
|
+
})
|
|
153
160
|
.catch(() => undefined);
|
|
154
161
|
}
|
|
155
162
|
|
|
@@ -307,7 +307,9 @@ async function runCommand(command, params, id) {
|
|
|
307
307
|
|
|
308
308
|
case Command.PRIORITIZE: {
|
|
309
309
|
const torrent = await requireTorrent(params.sourceKey);
|
|
310
|
-
pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes
|
|
310
|
+
pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes, {
|
|
311
|
+
wholeFileRead: params.wholeFileRead === true
|
|
312
|
+
});
|
|
311
313
|
return true;
|
|
312
314
|
}
|
|
313
315
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stamping a segment's position onto its fragments.
|
|
3
|
+
*
|
|
4
|
+
* A segment produced by the explicit-cut muxer holds SEVERAL fragments per
|
|
5
|
+
* track — `frag_keyframe` opens one at every keyframe, while a cut point comes
|
|
6
|
+
* only every few keyframes. Writing the segment's start into each of them made
|
|
7
|
+
* them all claim the same decode time, and the player re-fetched the segment
|
|
8
|
+
* forever. The positions inside a segment must therefore survive.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { stampSegmentStartTime } from "../services/segment-formats/mp4-boxes.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} type
|
|
17
|
+
* @param {Buffer} body
|
|
18
|
+
* @returns {Buffer}
|
|
19
|
+
*/
|
|
20
|
+
function box(type, body) {
|
|
21
|
+
const header = Buffer.alloc(8);
|
|
22
|
+
header.writeUInt32BE(8 + body.length, 0);
|
|
23
|
+
header.write(type, 4, "latin1");
|
|
24
|
+
return Buffer.concat([header, body]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One fragment: `moof > traf > (tfhd, tfdt)`, plus the payload it describes.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ trackId: number, decodeTime: number, version?: 0 | 1 }} fragment
|
|
31
|
+
* @returns {Buffer}
|
|
32
|
+
*/
|
|
33
|
+
function makeFragment({ trackId, decodeTime, version = 1 }) {
|
|
34
|
+
const tfhdBody = Buffer.alloc(8);
|
|
35
|
+
tfhdBody.writeUInt32BE(trackId, 4);
|
|
36
|
+
const tfdtBody = Buffer.alloc(version === 1 ? 12 : 8);
|
|
37
|
+
tfdtBody[0] = version;
|
|
38
|
+
if (version === 1) {
|
|
39
|
+
tfdtBody.writeBigUInt64BE(BigInt(decodeTime), 4);
|
|
40
|
+
} else {
|
|
41
|
+
tfdtBody.writeUInt32BE(decodeTime, 4);
|
|
42
|
+
}
|
|
43
|
+
const traf = box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
|
|
44
|
+
return Buffer.concat([box("moof", traf), box("mdat", Buffer.alloc(16, 0x5a))]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every `tfdt` in order, as `[trackId, decodeTime]`.
|
|
49
|
+
*
|
|
50
|
+
* @param {Buffer} segment
|
|
51
|
+
* @returns {Array<[number, number]>}
|
|
52
|
+
*/
|
|
53
|
+
function readDecodeTimes(segment) {
|
|
54
|
+
const found = [];
|
|
55
|
+
let offset = 0;
|
|
56
|
+
let trackId = 0;
|
|
57
|
+
while (offset + 8 <= segment.length) {
|
|
58
|
+
const size = segment.readUInt32BE(offset);
|
|
59
|
+
const type = segment.toString("latin1", offset + 4, offset + 8);
|
|
60
|
+
if (type === "tfhd") {
|
|
61
|
+
trackId = segment.readUInt32BE(offset + 12);
|
|
62
|
+
} else if (type === "tfdt") {
|
|
63
|
+
const version = segment[offset + 8];
|
|
64
|
+
found.push([
|
|
65
|
+
trackId,
|
|
66
|
+
version === 1 ? Number(segment.readBigUInt64BE(offset + 12)) : segment.readUInt32BE(offset + 12)
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
// Descend into the containers on the way to `tfdt`; skip anything else
|
|
70
|
+
// whole, so `mdat` is never walked into.
|
|
71
|
+
offset += type === "moof" || type === "traf" ? 8 : size;
|
|
72
|
+
}
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("fragments keep their distance from the start of the segment", () => {
|
|
77
|
+
// A 6 s segment holding three fragments at 0, 2 and 4 s of its own clock —
|
|
78
|
+
// the shape measured on the field host.
|
|
79
|
+
const timescale = 16_000;
|
|
80
|
+
const segment = Buffer.concat([
|
|
81
|
+
makeFragment({ trackId: 1, decodeTime: 0 }),
|
|
82
|
+
makeFragment({ trackId: 1, decodeTime: 2 * timescale }),
|
|
83
|
+
makeFragment({ trackId: 1, decodeTime: 4 * timescale })
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const stamped = stampSegmentStartTime(segment, 6, new Map([[1, timescale]]));
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
readDecodeTimes(stamped).map(([, time]) => time / timescale),
|
|
90
|
+
[6, 8, 10],
|
|
91
|
+
"each fragment must land at the segment's start plus its own offset"
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("each track is shifted by its own base", () => {
|
|
96
|
+
const videoScale = 16_000;
|
|
97
|
+
const audioScale = 44_100;
|
|
98
|
+
// Audio does not start at zero: its frames do not align with the video's.
|
|
99
|
+
const segment = Buffer.concat([
|
|
100
|
+
makeFragment({ trackId: 1, decodeTime: 0 }),
|
|
101
|
+
makeFragment({ trackId: 2, decodeTime: 1024 }),
|
|
102
|
+
makeFragment({ trackId: 1, decodeTime: 2 * videoScale }),
|
|
103
|
+
makeFragment({ trackId: 2, decodeTime: 1024 + 2 * audioScale })
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const stamped = stampSegmentStartTime(
|
|
107
|
+
segment,
|
|
108
|
+
6,
|
|
109
|
+
new Map([
|
|
110
|
+
[1, videoScale],
|
|
111
|
+
[2, audioScale]
|
|
112
|
+
])
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
assert.deepEqual(readDecodeTimes(stamped), [
|
|
116
|
+
[1, 6 * videoScale],
|
|
117
|
+
[2, 6 * audioScale],
|
|
118
|
+
[1, 8 * videoScale],
|
|
119
|
+
[2, 8 * audioScale]
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a single fragment is written outright, as before", () => {
|
|
124
|
+
const stamped = stampSegmentStartTime(
|
|
125
|
+
makeFragment({ trackId: 1, decodeTime: 1280 }),
|
|
126
|
+
12,
|
|
127
|
+
new Map([[1, 16_000]])
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(readDecodeTimes(stamped), [[1, 12 * 16_000]]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a 32-bit field too small for the position is left alone", () => {
|
|
133
|
+
const segment = makeFragment({ trackId: 1, decodeTime: 0, version: 0 });
|
|
134
|
+
const stamped = stampSegmentStartTime(segment, 1_000_000, new Map([[1, 90_000]]));
|
|
135
|
+
assert.deepEqual(
|
|
136
|
+
readDecodeTimes(stamped),
|
|
137
|
+
[[1, 0]],
|
|
138
|
+
"a wrapped value would send the player somewhere arbitrary; leaving it is the lesser harm"
|
|
139
|
+
);
|
|
140
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What WebTorrent's own selection bookkeeping does when a seek demotes
|
|
3
|
+
* the pieces behind the playhead.
|
|
4
|
+
*
|
|
5
|
+
* This is the library's behaviour, not ours, and `prioritizeByteRange` depends
|
|
6
|
+
* on it: a selection carries an `offset` — how many pieces from its start are
|
|
7
|
+
* already downloaded — and the picker scans from `from + offset`
|
|
8
|
+
* (`torrent.js`, `for (piece = next.from + next.offset; piece <= next.to; …)`).
|
|
9
|
+
* `deselect` subtracts an interval and copies that offset into what survives,
|
|
10
|
+
* so the remaining selection can end up scanning past its own end and yield
|
|
11
|
+
* nothing at all.
|
|
12
|
+
*
|
|
13
|
+
* Measured consequence before the fix: a seek to 89.1% of a 4.7 GB film left
|
|
14
|
+
* the seek target wanted by nobody, a later range-less read re-selected the
|
|
15
|
+
* whole file, and the swarm walked it from the first missing piece — 2.47 GB
|
|
16
|
+
* over 93 s before the segment could be served.
|
|
17
|
+
*
|
|
18
|
+
* If a WebTorrent upgrade changes any of this, these tests fail rather than the
|
|
19
|
+
* behaviour silently regressing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import test from "node:test";
|
|
23
|
+
import assert from "node:assert/strict";
|
|
24
|
+
import { Selections } from "webtorrent/lib/selections.js";
|
|
25
|
+
|
|
26
|
+
// The numbers are the measured session: 588 pieces of 8 MiB, sequential
|
|
27
|
+
// download had reached 38.4% (piece 226), the viewer seeked to 89.1% (piece
|
|
28
|
+
// 523).
|
|
29
|
+
const LAST_PIECE = 587;
|
|
30
|
+
const DOWNLOADED_TO = 226;
|
|
31
|
+
const PLAYHEAD = 523;
|
|
32
|
+
|
|
33
|
+
/** Where the picker would start scanning this selection. */
|
|
34
|
+
const scanStart = (selection) => selection.from + selection.offset;
|
|
35
|
+
|
|
36
|
+
test("deselecting the gap behind the playhead leaves a selection that yields nothing", () => {
|
|
37
|
+
const selections = new Selections();
|
|
38
|
+
selections.insert({ from: 0, to: LAST_PIECE, offset: 0, priority: 1 });
|
|
39
|
+
// What `_gcSelections` does as pieces arrive.
|
|
40
|
+
selections.get(0).offset = DOWNLOADED_TO;
|
|
41
|
+
|
|
42
|
+
selections.remove({ from: 0, to: PLAYHEAD - 1, isStreamSelection: false });
|
|
43
|
+
|
|
44
|
+
assert.equal(selections.length, 1);
|
|
45
|
+
const survivor = selections.get(0);
|
|
46
|
+
assert.equal(survivor.from, PLAYHEAD, "the surviving selection starts at the playhead");
|
|
47
|
+
assert.equal(survivor.offset, DOWNLOADED_TO, "and it kept the offset of the range it came from");
|
|
48
|
+
assert.ok(
|
|
49
|
+
scanStart(survivor) > survivor.to,
|
|
50
|
+
`scan would start at piece ${scanStart(survivor)} of ${survivor.to} — nothing is downloadable`
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("re-selecting the same range restores a scan that starts at the playhead", () => {
|
|
55
|
+
const selections = new Selections();
|
|
56
|
+
selections.insert({ from: 0, to: LAST_PIECE, offset: 0, priority: 1 });
|
|
57
|
+
selections.get(0).offset = DOWNLOADED_TO;
|
|
58
|
+
|
|
59
|
+
// Exactly what prioritizeByteRange does on a forward seek.
|
|
60
|
+
selections.remove({ from: 0, to: PLAYHEAD - 1, isStreamSelection: false });
|
|
61
|
+
selections.insert({ from: PLAYHEAD, to: LAST_PIECE, offset: 0, priority: 1 });
|
|
62
|
+
|
|
63
|
+
assert.equal(selections.length, 1, "the dead selection was replaced, not added to");
|
|
64
|
+
const selection = selections.get(0);
|
|
65
|
+
assert.equal(scanStart(selection), PLAYHEAD, "the picker now starts at the seek target");
|
|
66
|
+
assert.equal(selection.to, LAST_PIECE);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("selecting the whole file again undoes the demotion", () => {
|
|
70
|
+
const selections = new Selections();
|
|
71
|
+
selections.insert({ from: 0, to: LAST_PIECE, offset: 0, priority: 1 });
|
|
72
|
+
selections.get(0).offset = DOWNLOADED_TO;
|
|
73
|
+
selections.remove({ from: 0, to: PLAYHEAD - 1, isStreamSelection: false });
|
|
74
|
+
selections.insert({ from: PLAYHEAD, to: LAST_PIECE, offset: 0, priority: 1 });
|
|
75
|
+
|
|
76
|
+
// A range-less read reporting position 0 used to land here.
|
|
77
|
+
selections.insert({ from: 0, to: LAST_PIECE, offset: 0, priority: 1 });
|
|
78
|
+
|
|
79
|
+
assert.equal(selections.length, 1);
|
|
80
|
+
assert.equal(
|
|
81
|
+
scanStart(selections.get(0)),
|
|
82
|
+
0,
|
|
83
|
+
"the whole file is selected again, so the picker falls back to the first missing piece"
|
|
84
|
+
);
|
|
85
|
+
});
|
|
@@ -20,7 +20,7 @@ import { handleStreamGet } from "../routes/stream/get.js";
|
|
|
20
20
|
*/
|
|
21
21
|
function harness({ method, range }) {
|
|
22
22
|
const opened = [];
|
|
23
|
-
const state = { claims: 0 };
|
|
23
|
+
const state = { claims: 0, prioritized: [] };
|
|
24
24
|
|
|
25
25
|
const sent = { code: 200, headers: {}, body: undefined, called: false };
|
|
26
26
|
const reply = {
|
|
@@ -72,7 +72,9 @@ function harness({ method, range }) {
|
|
|
72
72
|
state.claims += 1;
|
|
73
73
|
return () => undefined;
|
|
74
74
|
},
|
|
75
|
-
prioritizeByteRange() {
|
|
75
|
+
prioritizeByteRange(_torrent, fileIndex, byteStart, _windowBytes, options) {
|
|
76
|
+
state.prioritized.push({ byteStart, wholeFileRead: options?.wholeFileRead === true });
|
|
77
|
+
}
|
|
76
78
|
};
|
|
77
79
|
|
|
78
80
|
const req = {
|
|
@@ -119,3 +121,25 @@ test("GET with a range streams only that range", async () => {
|
|
|
119
121
|
assert.equal(sent.headers["content-range"], "bytes 100-199/5869669065");
|
|
120
122
|
assert.equal(sent.headers["content-length"], "100");
|
|
121
123
|
});
|
|
124
|
+
|
|
125
|
+
// A request with no byte range says nothing about where the viewer is: ffmpeg
|
|
126
|
+
// opens its input with a plain GET and abandons it the moment it seeks, and the
|
|
127
|
+
// keyframe index and the codec probe do the same — four such reads around every
|
|
128
|
+
// encoder restart. Reported as ordinary reads at offset 0, they undid the seek
|
|
129
|
+
// that had just happened and sent the swarm walking the file from its first
|
|
130
|
+
// missing piece; a seek to 89.1% of a 4.7 GB film downloaded 2.47 GB that way.
|
|
131
|
+
test("a range-less GET is reported as a whole-file read", async () => {
|
|
132
|
+
const { req, reply, state, deps } = harness({ method: "GET" });
|
|
133
|
+
|
|
134
|
+
await handleStreamGet(req, reply, deps);
|
|
135
|
+
|
|
136
|
+
assert.deepEqual(state.prioritized, [{ byteStart: 0, wholeFileRead: true }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a ranged GET is reported as a real read position", async () => {
|
|
140
|
+
const { req, reply, state, deps } = harness({ method: "GET", range: "bytes=4390000000-" });
|
|
141
|
+
|
|
142
|
+
await handleStreamGet(req, reply, deps);
|
|
143
|
+
|
|
144
|
+
assert.deepEqual(state.prioritized, [{ byteStart: 4_390_000_000, wholeFileRead: false }]);
|
|
145
|
+
});
|