@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.87",
3
+ "version": "2.9.88",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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;
@@ -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(torrent, fileIndex, byteStart, windowBytes = PRIORITY_WINDOW_BYTES) {
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, { sourceKey, fileIndex, byteStart, windowBytes });
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({ sourceKey, fileIndex, byteStart, windowBytes })
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
+ });