@torrent-tv/proxy 2.66.1 → 2.68.0
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 +1327 -1314
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +13 -0
- package/server.js +7 -9
- package/services/supply-margin.js +74 -10
- package/services/torrent-worker/background-fill.js +213 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/piece-reader.js +109 -3
- package/services/torrent-worker/pool-adapter.js +20 -0
- package/services/torrent-worker/protocol.js +6 -0
- package/services/torrent-worker/worker.js +792 -784
- package/test/background-fill.test.js +190 -0
- package/test/supply-margin.test.js +59 -11
package/package.json
CHANGED
|
@@ -117,6 +117,19 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
|
|
|
117
117
|
};
|
|
118
118
|
for (const file of matched.audio) {
|
|
119
119
|
warmOne(file, { tailBytes: 0 });
|
|
120
|
+
// And then the whole of it, in the room the viewer's own reading leaves.
|
|
121
|
+
// The head is enough to NAME the track; it is not enough to play one, and
|
|
122
|
+
// a viewer who switches otherwise waits for the swarm to deliver its
|
|
123
|
+
// first pieces — 27.7 s in the field on 2026-08-31, longer than the
|
|
124
|
+
// switch is willing to wait. A soundtrack is about a twentieth of the
|
|
125
|
+
// picture, and the fill stands aside for every moment the picture's own
|
|
126
|
+
// reader is blocked, so it uses capacity the viewer is not using.
|
|
127
|
+
if (typeof torrentPool.fillFileInBackground === "function") {
|
|
128
|
+
Promise.resolve(torrentPool.fillFileInBackground(torrent, file.fileIndex)).catch((error) => {
|
|
129
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
130
|
+
logger.warn(`warm ${sourceKey.slice(0, 8)}: filling "${file.name}" failed: ${message}`);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
120
133
|
}
|
|
121
134
|
for (const file of matched.subtitles) {
|
|
122
135
|
warmOne(
|
package/server.js
CHANGED
|
@@ -206,15 +206,13 @@ export async function startProxyServer({
|
|
|
206
206
|
return;
|
|
207
207
|
}
|
|
208
208
|
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
timeoutMs: 600_000
|
|
217
|
-
});
|
|
209
|
+
// The same background fill the warm-up starts when a file is chosen, not a
|
|
210
|
+
// second way of doing it. It is guarded against running twice on one file,
|
|
211
|
+
// so the two triggers converge instead of putting two readers on the same
|
|
212
|
+
// soundtrack — and only one of them would have stood aside for the
|
|
213
|
+
// picture. This trigger remains for the session that never had a warm-up
|
|
214
|
+
// before it.
|
|
215
|
+
await torrentPool.fillFileInBackground?.(torrent, fileIndex);
|
|
218
216
|
}
|
|
219
217
|
});
|
|
220
218
|
const playbackPlanner = createPlaybackPlanner({
|
|
@@ -9,8 +9,22 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Both follow from the same two measured quantities, and from nothing else:
|
|
11
11
|
*
|
|
12
|
-
* W — how long
|
|
13
|
-
* T — how long
|
|
12
|
+
* W — how long the supply is INTERRUPTED (the worst recent stall);
|
|
13
|
+
* T — how long the encoder RUNS between two stalls (the median recent gap).
|
|
14
|
+
*
|
|
15
|
+
* Both words are load-bearing, and getting either wrong is what produced a
|
|
16
|
+
* demanded speed of 4422x on 2026-08-31.
|
|
17
|
+
*
|
|
18
|
+
* A stall is not a wait. Several readers walk one file — the picture and each
|
|
19
|
+
* audio rendition — so a piece that has not arrived blocks all of them, and
|
|
20
|
+
* their waits end within milliseconds of each other. Counted as separate
|
|
21
|
+
* interruptions they gave an interval of 0.00 s. Waits are therefore merged into
|
|
22
|
+
* the stretches during which the supply was not delivering, however many readers
|
|
23
|
+
* noticed.
|
|
24
|
+
*
|
|
25
|
+
* And T is the RUNNING time, from the end of one stall to the start of the next
|
|
26
|
+
* — not the spacing between their ends, which includes a stall's own duration
|
|
27
|
+
* and so credits the encoder with cushion it was not building.
|
|
14
28
|
*
|
|
15
29
|
* **The margin.** A step producing at speed `v` gains `v - 1` seconds of
|
|
16
30
|
* cushion for every second it runs, and an interruption of `W` seconds costs
|
|
@@ -19,10 +33,13 @@
|
|
|
19
33
|
*
|
|
20
34
|
* (v - 1) × T > W i.e. v > 1 + W / T
|
|
21
35
|
*
|
|
22
|
-
* On the field torrent:
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
36
|
+
* On the field torrent: stalls of 1.49 s with 0.73 s of running between them,
|
|
37
|
+
* the worst 3.16 s, so the honest bar is 5.33 — against the 1.5 that was
|
|
38
|
+
* assumed, and the 1.05 that was measured. (An earlier reading of the same data
|
|
39
|
+
* gave 2.42 by using the end-to-end spacing; it was too low in the same
|
|
40
|
+
* direction as the guess it replaced.) The same arithmetic explains why a copied
|
|
41
|
+
* stream never stalls: at 8x it gains 10.7 s between stalls and loses at most
|
|
42
|
+
* 4.8 s.
|
|
26
43
|
*
|
|
27
44
|
* **The buffer.** It must cover the worst interruption that can arrive before
|
|
28
45
|
* it can be refilled, whichever source that interruption comes from, plus the
|
|
@@ -62,10 +79,26 @@ export function requiredSpeedFrom(waits) {
|
|
|
62
79
|
if (ordered.length < 2) {
|
|
63
80
|
return null;
|
|
64
81
|
}
|
|
65
|
-
|
|
82
|
+
// One INTERRUPTION, not one wait. Several readers walk the same file — the
|
|
83
|
+
// picture and each audio rendition — and a piece that has not arrived blocks
|
|
84
|
+
// all of them at once. Counted as separate interruptions, those simultaneous
|
|
85
|
+
// waits gave a near-zero interval and therefore a required speed of thousands:
|
|
86
|
+
// measured 2026-08-31, `worst wait 13.26s, one every 0.00s, 2 measured` became
|
|
87
|
+
// 4422.00x, and every quality step was refused against it.
|
|
88
|
+
const interruptions = mergeOverlapping(ordered);
|
|
89
|
+
if (interruptions.length < 2) {
|
|
90
|
+
// One interruption shows no interval, and an interval invented from one
|
|
91
|
+
// point is exactly what this file exists to remove.
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const worstWaitSec = Math.max(...interruptions.map((one) => one.end - one.start)) / 1000;
|
|
66
95
|
const intervals = [];
|
|
67
|
-
for (let index = 1; index <
|
|
68
|
-
|
|
96
|
+
for (let index = 1; index < interruptions.length; index += 1) {
|
|
97
|
+
// From the END of one interruption to the START of the next: that is the
|
|
98
|
+
// stretch the encoder actually runs for and builds cushion in. Measuring
|
|
99
|
+
// end-to-end instead counted each interruption's own duration as part of
|
|
100
|
+
// the recovery it is supposed to be recovered from.
|
|
101
|
+
const gapMs = interruptions[index].start - interruptions[index - 1].end;
|
|
69
102
|
if (gapMs > 0) {
|
|
70
103
|
intervals.push(gapMs / 1000);
|
|
71
104
|
}
|
|
@@ -81,10 +114,41 @@ export function requiredSpeedFrom(waits) {
|
|
|
81
114
|
requiredSpeed: 1 + worstWaitSec / medianIntervalSec,
|
|
82
115
|
worstWaitSec,
|
|
83
116
|
medianIntervalSec,
|
|
84
|
-
|
|
117
|
+
// Interruptions, not waits: what the figure is derived from. The two differ
|
|
118
|
+
// whenever more than one reader walks the file, and reporting the raw count
|
|
119
|
+
// is what made the 4422x line look better evidenced than it was — "24
|
|
120
|
+
// measured" was 24 waits over far fewer actual stalls.
|
|
121
|
+
samples: interruptions.length,
|
|
122
|
+
waits: ordered.length
|
|
85
123
|
};
|
|
86
124
|
}
|
|
87
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Waits joined into the interruptions they actually were.
|
|
128
|
+
*
|
|
129
|
+
* A wait spans `[at - waitedMs, at]`. Two that overlap or touch are one stretch
|
|
130
|
+
* during which the supply was not delivering, however many readers noticed it.
|
|
131
|
+
*
|
|
132
|
+
* @param {SupplyWait[]} ordered - Usable waits, oldest END first.
|
|
133
|
+
* @returns {Array<{ start: number, end: number }>} Disjoint, in time order.
|
|
134
|
+
*/
|
|
135
|
+
function mergeOverlapping(ordered) {
|
|
136
|
+
const spans = ordered
|
|
137
|
+
.map((wait) => ({ start: wait.at - wait.waitedMs, end: wait.at }))
|
|
138
|
+
.sort((left, right) => left.start - right.start);
|
|
139
|
+
/** @type {Array<{ start: number, end: number }>} */
|
|
140
|
+
const merged = [];
|
|
141
|
+
for (const span of spans) {
|
|
142
|
+
const last = merged[merged.length - 1];
|
|
143
|
+
if (last && span.start <= last.end) {
|
|
144
|
+
last.end = Math.max(last.end, span.end);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
merged.push({ ...span });
|
|
148
|
+
}
|
|
149
|
+
return merged;
|
|
150
|
+
}
|
|
151
|
+
|
|
88
152
|
/**
|
|
89
153
|
* The smallest buffer at which no interruption reaches the viewer.
|
|
90
154
|
*
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch a whole file that nobody is playing yet, using only the room the viewer
|
|
3
|
+
* is not using.
|
|
4
|
+
*
|
|
5
|
+
* WHAT IT IS FOR. A release ships its dub and its subtitles as separate files,
|
|
6
|
+
* and a viewer who switches to one waits for the swarm to deliver its first
|
|
7
|
+
* pieces — 27.7 s in the field on 2026-08-31, which is longer than the switch is
|
|
8
|
+
* willing to wait. These files are small beside the picture (30 MB against 566
|
|
9
|
+
* MB, about a twentieth), so having them on disk before anyone asks turns every
|
|
10
|
+
* later switch into a local read.
|
|
11
|
+
*
|
|
12
|
+
* THE ORDERING, WHICH IS THE WHOLE DESIGN. What plays now comes first: the
|
|
13
|
+
* picture at the playhead, the soundtrack being heard, the subtitles being
|
|
14
|
+
* shown. The other soundtracks and subtitle files come next. Reading the film
|
|
15
|
+
* far ahead comes last. This module implements the middle tier, and it stays
|
|
16
|
+
* below the first by a condition that is measured rather than chosen: it fetches
|
|
17
|
+
* only while NO reader on the torrent is inside a wait. A reader blocked on a
|
|
18
|
+
* piece is the viewer's own reading starving, and that is exactly the moment
|
|
19
|
+
* this must not be asking the swarm for anything.
|
|
20
|
+
*
|
|
21
|
+
* WHY IT IS A READ AND NOT A SELECTION. `file.select()` claims every piece of a
|
|
22
|
+
* file at once. `#syncSelections` in `torrent-pool.js` records what that cost
|
|
23
|
+
* when it was done alongside the readers' own moving windows: a claim covering
|
|
24
|
+
* everything always outranked the window, and a seek to 89.1 % of a 4.7 GB film
|
|
25
|
+
* waited 93 s while the swarm fetched 2.47 GB in file order. So this walks the
|
|
26
|
+
* file a piece at a time through an ordinary bounded read, which claims what it
|
|
27
|
+
* is reading and gives it back.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { logger } from "../../utils/logger.js";
|
|
31
|
+
import { readersAreBlockedOn, stallsSeenOn } from "./piece-reader.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How long to stand aside after finding the viewer's own reading blocked.
|
|
35
|
+
*
|
|
36
|
+
* Not a measurement of anything: it is how often the question "is the viewer
|
|
37
|
+
* still starving?" is asked, and it is answered by the reader count, which is
|
|
38
|
+
* exact. Short enough that room is used soon after it appears, long enough that
|
|
39
|
+
* asking costs nothing.
|
|
40
|
+
*/
|
|
41
|
+
const STAND_ASIDE_MS = 1_000;
|
|
42
|
+
|
|
43
|
+
/** Files being filled, by `sourceKey:fileIndex`, so one runs per file. */
|
|
44
|
+
const running = new Map();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {number} ms
|
|
48
|
+
* @returns {Promise<void>}
|
|
49
|
+
*/
|
|
50
|
+
function pause(ms) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const timer = setTimeout(resolve, ms);
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read one byte range, letting the torrent fetch what is missing.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} file
|
|
61
|
+
* @param {number} start
|
|
62
|
+
* @param {number} end - Inclusive.
|
|
63
|
+
* @returns {Promise<number>} Bytes read; 0 on failure.
|
|
64
|
+
*/
|
|
65
|
+
function readRange(file, start, end) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
let stream;
|
|
68
|
+
try {
|
|
69
|
+
stream = file.createReadStream({ start, end });
|
|
70
|
+
} catch {
|
|
71
|
+
resolve(0);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let bytes = 0;
|
|
75
|
+
let settled = false;
|
|
76
|
+
const settle = (value) => {
|
|
77
|
+
if (settled) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
settled = true;
|
|
81
|
+
resolve(value);
|
|
82
|
+
};
|
|
83
|
+
stream.on("data", (chunk) => {
|
|
84
|
+
bytes += chunk.length;
|
|
85
|
+
});
|
|
86
|
+
stream.on("end", () => settle(bytes));
|
|
87
|
+
stream.on("error", () => {
|
|
88
|
+
stream.destroy?.();
|
|
89
|
+
settle(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fetch a file whole, in the room the viewer leaves.
|
|
96
|
+
*
|
|
97
|
+
* Returns as soon as the work is under way; the caller is not waiting for it.
|
|
98
|
+
* One fill per file — a second request while one is running is ignored rather
|
|
99
|
+
* than doubling the reads.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} torrent
|
|
102
|
+
* @param {number} fileIndex
|
|
103
|
+
* @param {string} sourceKey
|
|
104
|
+
* @param {{ chunkBytes?: number, isBlocked?: (infoHash: string) => boolean, stallsSeen?: (infoHash: string) => number }} [options]
|
|
105
|
+
* `isBlocked` answers "is the viewer's own reading starving right now"; it is
|
|
106
|
+
* the tier boundary, and it is a parameter so it can be exercised without a
|
|
107
|
+
* swarm.
|
|
108
|
+
* @returns {boolean} Whether a fill was started by this call.
|
|
109
|
+
*/
|
|
110
|
+
export function fillFileInBackground(torrent, fileIndex, sourceKey, options = {}) {
|
|
111
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
112
|
+
if (running.has(key)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const file = torrent?.files?.[fileIndex];
|
|
116
|
+
if (!file || !Number.isFinite(file.length) || file.length <= 0) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
// One piece at a time: the smallest unit the swarm actually delivers, so the
|
|
120
|
+
// gap between two checks of "is the viewer starving?" is as short as it can
|
|
121
|
+
// usefully be.
|
|
122
|
+
const chunkBytes = Number.isFinite(options.chunkBytes) && options.chunkBytes > 0
|
|
123
|
+
? options.chunkBytes
|
|
124
|
+
: (Number(torrent?.pieceLength) || 4 * 1024 * 1024);
|
|
125
|
+
const isBlocked = typeof options.isBlocked === "function" ? options.isBlocked : readersAreBlockedOn;
|
|
126
|
+
const stallsSeen = typeof options.stallsSeen === "function" ? options.stallsSeen : stallsSeenOn;
|
|
127
|
+
const work = fill(torrent, file, fileIndex, chunkBytes, isBlocked, stallsSeen).finally(() => {
|
|
128
|
+
running.delete(key);
|
|
129
|
+
});
|
|
130
|
+
running.set(key, work);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {object} torrent
|
|
136
|
+
* @param {object} file
|
|
137
|
+
* @param {number} fileIndex
|
|
138
|
+
* @param {number} chunkBytes
|
|
139
|
+
* @param {(infoHash: string) => boolean} isBlocked
|
|
140
|
+
* @param {(infoHash: string) => number} stallsSeen
|
|
141
|
+
* @returns {Promise<void>}
|
|
142
|
+
*/
|
|
143
|
+
async function fill(torrent, file, fileIndex, chunkBytes, isBlocked, stallsSeen) {
|
|
144
|
+
const startedAt = Date.now();
|
|
145
|
+
const infoHash = String(torrent?.infoHash ?? "");
|
|
146
|
+
let read = 0;
|
|
147
|
+
let stoodAsideMs = 0;
|
|
148
|
+
// The stall count this fill last saw. A chunk is fetched only when it has not
|
|
149
|
+
// moved since the previous one.
|
|
150
|
+
let quietSince = stallsSeen(infoHash);
|
|
151
|
+
logger.info(
|
|
152
|
+
`background-fill: "${String(file.name).slice(0, 40)}" (${(file.length / 1e6).toFixed(1)}MB) will be ` +
|
|
153
|
+
"fetched whole while the viewer's own reading leaves room"
|
|
154
|
+
);
|
|
155
|
+
for (let start = 0; start < file.length; start += chunkBytes) {
|
|
156
|
+
// Stand aside while anything the viewer is watching is waiting on the
|
|
157
|
+
// swarm — AND for as long after it as it takes for a quiet stretch to
|
|
158
|
+
// pass. Pausing only DURING a stall is not enough: on a swarm delivering
|
|
159
|
+
// exactly what the film needs, this still takes bandwidth between stalls,
|
|
160
|
+
// and the stalls themselves are the proof there was none to spare. Field
|
|
161
|
+
// 2026-08-31, the case that forced this: 200-600 KB/s delivered against
|
|
162
|
+
// the 399 KB/s the film eats, one piece waited 101 s, and the picture
|
|
163
|
+
// stood still 145.6 s.
|
|
164
|
+
//
|
|
165
|
+
// "A quiet stretch" is measured, not chosen: the stall counter must not
|
|
166
|
+
// have moved while the previous chunk was being fetched. On a starving
|
|
167
|
+
// swarm it moves constantly and this stops altogether, which is the right
|
|
168
|
+
// answer — there is no spare room to use.
|
|
169
|
+
while (isBlocked(infoHash) || stallsSeen(infoHash) !== quietSince) {
|
|
170
|
+
stoodAsideMs += STAND_ASIDE_MS;
|
|
171
|
+
await pause(STAND_ASIDE_MS);
|
|
172
|
+
// Re-baselined after the pause, so a stretch that passes without a new
|
|
173
|
+
// stall lets the fill go on. Without this it could never resume.
|
|
174
|
+
quietSince = stallsSeen(infoHash);
|
|
175
|
+
}
|
|
176
|
+
// The torrent may have been destroyed under us — a viewer who left, the
|
|
177
|
+
// disk sweep, a restart. Reading a destroyed file throws, and there is
|
|
178
|
+
// nothing here worth an error.
|
|
179
|
+
if (!torrent?.files?.[fileIndex]) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Taken BEFORE the read, and deliberately not refreshed after it: a stall
|
|
183
|
+
// that happens while this chunk is in flight must still be visible to the
|
|
184
|
+
// next iteration. Refreshing afterwards erased exactly that evidence, which
|
|
185
|
+
// is the defect a test caught here.
|
|
186
|
+
quietSince = stallsSeen(infoHash);
|
|
187
|
+
const bytes = await readRange(file, start, Math.min(start + chunkBytes, file.length) - 1);
|
|
188
|
+
if (bytes === 0) {
|
|
189
|
+
logger.info(
|
|
190
|
+
`background-fill: "${String(file.name).slice(0, 40)}" stopped at ` +
|
|
191
|
+
`${(start / 1e6).toFixed(1)}MB — the read returned nothing`
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
read += bytes;
|
|
196
|
+
}
|
|
197
|
+
logger.info(
|
|
198
|
+
`background-fill: "${String(file.name).slice(0, 40)}" is on disk — ${(read / 1e6).toFixed(1)}MB in ` +
|
|
199
|
+
`${((Date.now() - startedAt) / 1000).toFixed(0)}s, of which ${(stoodAsideMs / 1000).toFixed(0)}s ` +
|
|
200
|
+
"was spent standing aside for the viewer; a switch to it will not wait for the swarm"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Whether a file is being filled right now. For tests and for the log.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} sourceKey
|
|
208
|
+
* @param {number} fileIndex
|
|
209
|
+
* @returns {boolean}
|
|
210
|
+
*/
|
|
211
|
+
export function fillIsRunning(sourceKey, fileIndex) {
|
|
212
|
+
return running.has(`${sourceKey}:${fileIndex}`);
|
|
213
|
+
}
|
|
@@ -321,6 +321,16 @@ export class TorrentWorkerClient {
|
|
|
321
321
|
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
322
322
|
* @returns {Promise<{ tracks: object[] }>}
|
|
323
323
|
*/
|
|
324
|
+
/**
|
|
325
|
+
* Fetch one whole file in the room the viewer's own reading leaves.
|
|
326
|
+
*
|
|
327
|
+
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
328
|
+
* @returns {Promise<{ started: boolean }>}
|
|
329
|
+
*/
|
|
330
|
+
async fillFile({ sourceKey, fileIndex }) {
|
|
331
|
+
return this.#caller.call(Command.FILL_FILE, { sourceKey, fileIndex });
|
|
332
|
+
}
|
|
333
|
+
|
|
324
334
|
async getSubtitleTracks({ sourceKey, fileIndex }) {
|
|
325
335
|
return this.#caller.call(Command.SUBTITLE_TRACKS, { sourceKey, fileIndex });
|
|
326
336
|
}
|
|
@@ -600,6 +600,80 @@ function describeSteering(key) {
|
|
|
600
600
|
);
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
+
/**
|
|
604
|
+
* How many readers are blocked on a torrent AT THIS MOMENT, by infohash.
|
|
605
|
+
*
|
|
606
|
+
* Not a history and not an average: the question it answers is "is anything the
|
|
607
|
+
* viewer is watching waiting for the swarm right now", and the only honest
|
|
608
|
+
* answer is a count of readers currently inside a wait.
|
|
609
|
+
*
|
|
610
|
+
* It exists so that work which is NOT what the viewer is watching — fetching a
|
|
611
|
+
* soundtrack or a subtitle file they may switch to later — can proceed while the
|
|
612
|
+
* swarm has room and stand aside the instant it does not. That ordering is the
|
|
613
|
+
* whole of the requirement: the picture and the track being played come first,
|
|
614
|
+
* the other tracks next, and reading the film far ahead last.
|
|
615
|
+
*
|
|
616
|
+
* @type {Map<string, number>}
|
|
617
|
+
*/
|
|
618
|
+
const blockedReaders = new Map();
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* How many stalls a torrent's readers have had, ever. Only differences between
|
|
622
|
+
* two readings of it mean anything.
|
|
623
|
+
*
|
|
624
|
+
* @type {Map<string, number>}
|
|
625
|
+
*/
|
|
626
|
+
const stallsSeen = new Map();
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Whether any reader on this torrent is waiting for a piece right now.
|
|
630
|
+
*
|
|
631
|
+
* @param {string} infoHash
|
|
632
|
+
* @returns {boolean}
|
|
633
|
+
*/
|
|
634
|
+
export function readersAreBlockedOn(infoHash) {
|
|
635
|
+
return (blockedReaders.get(infoHash) ?? 0) > 0;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* How many times a reader on this torrent has been blocked since the process
|
|
640
|
+
* started.
|
|
641
|
+
*
|
|
642
|
+
* Exists so that work of lower importance can ask "did the viewer stall while I
|
|
643
|
+
* was busy?" — which is a different and stricter question than "is the viewer
|
|
644
|
+
* stalled right now". On a swarm delivering exactly what the film needs, a
|
|
645
|
+
* background fetch that only pauses DURING a stall still takes bandwidth
|
|
646
|
+
* between them, and the stalls are the proof it had none to spare. Field
|
|
647
|
+
* 2026-08-31: the swarm delivered 200-600 KB/s against the 399 KB/s the film
|
|
648
|
+
* needs, and the picture stood still 145.6 s.
|
|
649
|
+
*
|
|
650
|
+
* @param {string} infoHash
|
|
651
|
+
* @returns {number}
|
|
652
|
+
*/
|
|
653
|
+
export function stallsSeenOn(infoHash) {
|
|
654
|
+
return stallsSeen.get(infoHash) ?? 0;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* @param {string} infoHash
|
|
659
|
+
* @param {number} delta
|
|
660
|
+
* @returns {void}
|
|
661
|
+
*/
|
|
662
|
+
function countBlockedReader(infoHash, delta) {
|
|
663
|
+
if (!infoHash) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (delta > 0) {
|
|
667
|
+
stallsSeen.set(infoHash, (stallsSeen.get(infoHash) ?? 0) + 1);
|
|
668
|
+
}
|
|
669
|
+
const next = (blockedReaders.get(infoHash) ?? 0) + delta;
|
|
670
|
+
if (next > 0) {
|
|
671
|
+
blockedReaders.set(infoHash, next);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
blockedReaders.delete(infoHash);
|
|
675
|
+
}
|
|
676
|
+
|
|
603
677
|
function noteSupplyWait(key, label, waitedMs) {
|
|
604
678
|
const history = supplyWaits.get(key) ?? [];
|
|
605
679
|
history.push({ waitedMs, at: Date.now() });
|
|
@@ -623,8 +697,14 @@ function noteSupplyWait(key, label, waitedMs) {
|
|
|
623
697
|
});
|
|
624
698
|
logger.info(
|
|
625
699
|
`supply "${label.slice(0, 40)}": a step must run at ${demand.requiredSpeed.toFixed(2)}x ` +
|
|
626
|
-
|
|
627
|
-
|
|
700
|
+
// "Interruption", not "wait": several readers walk one file — the picture
|
|
701
|
+
// and each audio rendition — so one missing piece produces one stall and
|
|
702
|
+
// several waits. Saying how many of each is what makes the figure readable;
|
|
703
|
+
// reporting the waits alone made `2 measured` look like two interruptions
|
|
704
|
+
// 3 ms apart, and the demanded speed came out at 4422x.
|
|
705
|
+
`to survive this swarm (worst stall ${demand.worstWaitSec.toFixed(2)}s, one every ` +
|
|
706
|
+
`${demand.medianIntervalSec.toFixed(2)}s of running, ${demand.samples} stall(s) ` +
|
|
707
|
+
`from ${demand.waits} wait(s)) — ` +
|
|
628
708
|
`and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s` +
|
|
629
709
|
// What steering the blocked piece onto another peer bought, as the
|
|
630
710
|
// difference between the waits where it placed something and the waits
|
|
@@ -890,6 +970,15 @@ export async function* readFragments({
|
|
|
890
970
|
}
|
|
891
971
|
|
|
892
972
|
const waitStartedAt = Date.now();
|
|
973
|
+
// What the WHOLE torrent received while this one piece was missing. It is
|
|
974
|
+
// the reading that separates the two causes a wait can have, and neither
|
|
975
|
+
// could be told from the other before: bytes arriving briskly throughout
|
|
976
|
+
// mean the swarm had capacity and this piece was stuck behind the wire
|
|
977
|
+
// that reserved it — the blocked-piece tail; bytes barely moving mean
|
|
978
|
+
// there was nothing to be had, and no reordering of requests would have
|
|
979
|
+
// helped. Asked of 2026-08-31, when a session with 4-5 peers of 38 known
|
|
980
|
+
// waited 41.32 s at worst and the log could not say which it was.
|
|
981
|
+
const downloadedAtWaitStart = Number(torrent?.downloaded) || 0;
|
|
893
982
|
// The reader is blocked, so this piece is now the only thing that matters
|
|
894
983
|
// on this torrent: hand it to the fastest wires that hold it. A block is
|
|
895
984
|
// reserved for exactly one wire, and the read ends when the slowest
|
|
@@ -955,9 +1044,16 @@ export async function* readFragments({
|
|
|
955
1044
|
// moment ago may now be the fastest one available.
|
|
956
1045
|
pushToFastest();
|
|
957
1046
|
}, 500);
|
|
1047
|
+
// Counted for exactly as long as this reader is inside the wait, so that
|
|
1048
|
+
// background work can stand aside while the viewer's own reading is
|
|
1049
|
+
// starving. The `finally` is what makes it safe: a cancelled or failed
|
|
1050
|
+
// read must not leave the torrent looking permanently blocked, which
|
|
1051
|
+
// would stop that background work for the rest of the session.
|
|
1052
|
+
countBlockedReader(torrent?.infoHash, 1);
|
|
958
1053
|
try {
|
|
959
1054
|
await whenPieceReady(torrent, pieceIndex, cancellation);
|
|
960
1055
|
} finally {
|
|
1056
|
+
countBlockedReader(torrent?.infoHash, -1);
|
|
961
1057
|
clearInterval(supplyProbe);
|
|
962
1058
|
}
|
|
963
1059
|
// What a reader spent waiting for data, attributed to the exact piece. A
|
|
@@ -999,11 +1095,21 @@ export async function* readFragments({
|
|
|
999
1095
|
}
|
|
1000
1096
|
if (waitedMs >= PIECE_WAIT_LOG_MS) {
|
|
1001
1097
|
const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
|
|
1098
|
+
// Two rates, side by side, and no verdict word between them: the swarm's
|
|
1099
|
+
// own delivery during the wait against what this piece managed. Both are
|
|
1100
|
+
// measured; which one a reader calls "the cause" follows from the pair
|
|
1101
|
+
// without a threshold having to be chosen here. Far apart means the
|
|
1102
|
+
// bytes were flowing and this piece was not among them; close together,
|
|
1103
|
+
// or both near zero, means there was nothing to deliver.
|
|
1104
|
+
const swarmBytes = Math.max(0, (Number(torrent?.downloaded) || 0) - downloadedAtWaitStart);
|
|
1105
|
+
const swarmKbps = Math.round(swarmBytes / 1024 / (waitedMs / 1000));
|
|
1002
1106
|
logger.info(
|
|
1003
1107
|
`piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
|
|
1004
1108
|
`(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
|
|
1005
1109
|
`${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}") ` +
|
|
1006
|
-
`— ${rateKbps}KB/s on this piece
|
|
1110
|
+
`— ${rateKbps}KB/s on this piece while the swarm delivered ` +
|
|
1111
|
+
`${swarmKbps}KB/s (${(swarmBytes / 1024 / 1024).toFixed(1)}MB) across the torrent, ` +
|
|
1112
|
+
`${Number(torrent?.numPeers) || 0} peers connected; ` +
|
|
1007
1113
|
(supply
|
|
1008
1114
|
? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
|
|
1009
1115
|
`${supply.blocks} blocks (${Math.round((supply.blocks * 16384) / 1024)}KB) in flight at peak`
|
|
@@ -134,6 +134,26 @@ export class WorkerTorrentPool {
|
|
|
134
134
|
* @param {number} fileIndex
|
|
135
135
|
* @returns {Promise<object[]>}
|
|
136
136
|
*/
|
|
137
|
+
/**
|
|
138
|
+
* Fetch one whole file using only the room the viewer's own reading leaves.
|
|
139
|
+
*
|
|
140
|
+
* For a soundtrack or subtitle file shipped beside the picture: small next to
|
|
141
|
+
* the film, and having it on disk is what turns a later switch into a local
|
|
142
|
+
* read instead of a wait on the swarm.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} torrent
|
|
145
|
+
* @param {number} fileIndex
|
|
146
|
+
* @returns {Promise<boolean>} Whether a fill was started by this call.
|
|
147
|
+
*/
|
|
148
|
+
async fillFileInBackground(torrent, fileIndex) {
|
|
149
|
+
const sourceKey = torrent?.sourceKey;
|
|
150
|
+
if (!sourceKey) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const answer = await this.#client.fillFile({ sourceKey, fileIndex });
|
|
154
|
+
return answer?.started === true;
|
|
155
|
+
}
|
|
156
|
+
|
|
137
157
|
async getSubtitleTracks(torrent, fileIndex) {
|
|
138
158
|
const sourceKey = torrent?.sourceKey;
|
|
139
159
|
if (!sourceKey) {
|
|
@@ -65,6 +65,12 @@ export const Command = {
|
|
|
65
65
|
CANCEL_READ: "cancel-read",
|
|
66
66
|
/** Pre-fetch the head and tail a codec probe needs. */
|
|
67
67
|
PREFETCH_EDGES: "prefetch-edges",
|
|
68
|
+
/**
|
|
69
|
+
* Fetch one whole file using only the room the viewer's own reading leaves —
|
|
70
|
+
* a soundtrack or subtitle file they may switch to later. Returns as soon as
|
|
71
|
+
* the work is under way.
|
|
72
|
+
*/
|
|
73
|
+
FILL_FILE: "fill-file",
|
|
68
74
|
/** The text subtitle tracks a file carries, for the viewer's menu. */
|
|
69
75
|
SUBTITLE_TRACKS: "subtitle-tracks",
|
|
70
76
|
/**
|