@torrent-tv/proxy 2.9.87 → 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 +4 -0
- package/package.json +1 -1
- package/routes/stream/get.js +3 -1
- 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/piece-selection-offset.test.js +85 -0
- package/test/stream-route.test.js +26 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
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
|
+
|
|
1
5
|
## 2.9.87
|
|
2
6
|
|
|
3
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.
|
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;
|
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,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
|
+
});
|