@torrent-tv/proxy 2.66.0 → 2.67.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 +1322 -1310
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +13 -0
- package/server.js +7 -9
- package/services/hls-session-manager.js +40 -0
- package/services/supply-margin.js +74 -10
- package/services/torrent-worker/background-fill.js +191 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/piece-reader.js +79 -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 +156 -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({
|
|
@@ -4440,6 +4440,27 @@ export class HlsSessionManager {
|
|
|
4440
4440
|
const next = this.#splicableHeights(base)
|
|
4441
4441
|
.find((height) => height < current && offered.includes(height));
|
|
4442
4442
|
if (next === undefined) {
|
|
4443
|
+
// Nothing lower — but "lower" is not the same question as "cheaper", and
|
|
4444
|
+
// on a source that is COPIED the answer is above, not below. A copied
|
|
4445
|
+
// rung costs no encoder at all, whatever its size, so when a re-encode
|
|
4446
|
+
// cannot keep up it is both the fastest thing this host can serve AND the
|
|
4447
|
+
// best picture it has.
|
|
4448
|
+
//
|
|
4449
|
+
// Field 2026-08-31, and it cost the viewer the whole film: an ultrafast
|
|
4450
|
+
// 444x240 encode ran at 0.43-0.94x for fifty minutes while the source's
|
|
4451
|
+
// own 1038p sat on offer beside it, copied and free. This line printed
|
|
4452
|
+
// fifty times — "nothing lower is on offer; leaving the picture alone" —
|
|
4453
|
+
// and the picture stood still 161 times for 940 seconds. The rescue was
|
|
4454
|
+
// on the screen the whole time and the rule could only look down.
|
|
4455
|
+
const copied = this.#copiedHeightOf(base);
|
|
4456
|
+
if (copied > 0 && copied !== current && offered.includes(copied)) {
|
|
4457
|
+
logger.info(
|
|
4458
|
+
`[budget] transcode ${session.id} ${reasonText} at ${current}p and nothing lower is on offer, ` +
|
|
4459
|
+
`but ${copied}p is COPIED on this file — no encoder at all, and a better picture. ` +
|
|
4460
|
+
`Asking for it instead of leaving the viewer on an encode that cannot keep up`
|
|
4461
|
+
);
|
|
4462
|
+
return this.#askQualityHeight(base, copied, reasonText);
|
|
4463
|
+
}
|
|
4443
4464
|
logger.info(
|
|
4444
4465
|
`[budget] transcode ${session.id} ${reasonText} at ${current}p, but nothing lower is on offer ` +
|
|
4445
4466
|
`for "${session.fileName}"; leaving the picture alone`
|
|
@@ -4449,6 +4470,25 @@ export class HlsSessionManager {
|
|
|
4449
4470
|
return this.#askQualityHeight(base, next, reasonText);
|
|
4450
4471
|
}
|
|
4451
4472
|
|
|
4473
|
+
/**
|
|
4474
|
+
* The height this family serves by COPY, or zero when every rung is encoded.
|
|
4475
|
+
*
|
|
4476
|
+
* The one rung whose cost does not depend on the machine: the source's own
|
|
4477
|
+
* height, on a base whose video is not re-encoded. `offeredHeights` never
|
|
4478
|
+
* withdraws it for that reason, so it is always available as somewhere to
|
|
4479
|
+
* return to — which is exactly what {@link HlsSessionManager##askLowerHeight}
|
|
4480
|
+
* had no way to say.
|
|
4481
|
+
*
|
|
4482
|
+
* @param {HlsSession} base
|
|
4483
|
+
* @returns {number}
|
|
4484
|
+
*/
|
|
4485
|
+
#copiedHeightOf(base) {
|
|
4486
|
+
if (!base || base.transcodeVideo === true) {
|
|
4487
|
+
return 0;
|
|
4488
|
+
}
|
|
4489
|
+
return Math.round(Number(base.sourceHeight) || 0);
|
|
4490
|
+
}
|
|
4491
|
+
|
|
4452
4492
|
/**
|
|
4453
4493
|
* Record a request to the viewer's player to move to another variant.
|
|
4454
4494
|
*
|
|
@@ -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,191 @@
|
|
|
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 } 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 }} [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 work = fill(torrent, file, fileIndex, chunkBytes, isBlocked).finally(() => {
|
|
127
|
+
running.delete(key);
|
|
128
|
+
});
|
|
129
|
+
running.set(key, work);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {object} torrent
|
|
135
|
+
* @param {object} file
|
|
136
|
+
* @param {number} fileIndex
|
|
137
|
+
* @param {number} chunkBytes
|
|
138
|
+
* @param {(infoHash: string) => boolean} isBlocked
|
|
139
|
+
* @returns {Promise<void>}
|
|
140
|
+
*/
|
|
141
|
+
async function fill(torrent, file, fileIndex, chunkBytes, isBlocked) {
|
|
142
|
+
const startedAt = Date.now();
|
|
143
|
+
const infoHash = String(torrent?.infoHash ?? "");
|
|
144
|
+
let read = 0;
|
|
145
|
+
let stoodAsideMs = 0;
|
|
146
|
+
logger.info(
|
|
147
|
+
`background-fill: "${String(file.name).slice(0, 40)}" (${(file.length / 1e6).toFixed(1)}MB) will be ` +
|
|
148
|
+
"fetched whole while the viewer's own reading leaves room"
|
|
149
|
+
);
|
|
150
|
+
for (let start = 0; start < file.length; start += chunkBytes) {
|
|
151
|
+
// Stand aside for as long as anything the viewer is watching is waiting on
|
|
152
|
+
// the swarm. This is the tier boundary, and it is checked before every
|
|
153
|
+
// chunk rather than once at the beginning: a torrent that was healthy a
|
|
154
|
+
// moment ago is not evidence about the next second.
|
|
155
|
+
while (isBlocked(infoHash)) {
|
|
156
|
+
stoodAsideMs += STAND_ASIDE_MS;
|
|
157
|
+
await pause(STAND_ASIDE_MS);
|
|
158
|
+
}
|
|
159
|
+
// The torrent may have been destroyed under us — a viewer who left, the
|
|
160
|
+
// disk sweep, a restart. Reading a destroyed file throws, and there is
|
|
161
|
+
// nothing here worth an error.
|
|
162
|
+
if (!torrent?.files?.[fileIndex]) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const bytes = await readRange(file, start, Math.min(start + chunkBytes, file.length) - 1);
|
|
166
|
+
if (bytes === 0) {
|
|
167
|
+
logger.info(
|
|
168
|
+
`background-fill: "${String(file.name).slice(0, 40)}" stopped at ` +
|
|
169
|
+
`${(start / 1e6).toFixed(1)}MB — the read returned nothing`
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
read += bytes;
|
|
174
|
+
}
|
|
175
|
+
logger.info(
|
|
176
|
+
`background-fill: "${String(file.name).slice(0, 40)}" is on disk — ${(read / 1e6).toFixed(1)}MB in ` +
|
|
177
|
+
`${((Date.now() - startedAt) / 1000).toFixed(0)}s, of which ${(stoodAsideMs / 1000).toFixed(0)}s ` +
|
|
178
|
+
"was spent standing aside for the viewer; a switch to it will not wait for the swarm"
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether a file is being filled right now. For tests and for the log.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} sourceKey
|
|
186
|
+
* @param {number} fileIndex
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
export function fillIsRunning(sourceKey, fileIndex) {
|
|
190
|
+
return running.has(`${sourceKey}:${fileIndex}`);
|
|
191
|
+
}
|
|
@@ -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,50 @@ 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
|
+
* Whether any reader on this torrent is waiting for a piece right now.
|
|
622
|
+
*
|
|
623
|
+
* @param {string} infoHash
|
|
624
|
+
* @returns {boolean}
|
|
625
|
+
*/
|
|
626
|
+
export function readersAreBlockedOn(infoHash) {
|
|
627
|
+
return (blockedReaders.get(infoHash) ?? 0) > 0;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* @param {string} infoHash
|
|
632
|
+
* @param {number} delta
|
|
633
|
+
* @returns {void}
|
|
634
|
+
*/
|
|
635
|
+
function countBlockedReader(infoHash, delta) {
|
|
636
|
+
if (!infoHash) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const next = (blockedReaders.get(infoHash) ?? 0) + delta;
|
|
640
|
+
if (next > 0) {
|
|
641
|
+
blockedReaders.set(infoHash, next);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
blockedReaders.delete(infoHash);
|
|
645
|
+
}
|
|
646
|
+
|
|
603
647
|
function noteSupplyWait(key, label, waitedMs) {
|
|
604
648
|
const history = supplyWaits.get(key) ?? [];
|
|
605
649
|
history.push({ waitedMs, at: Date.now() });
|
|
@@ -623,8 +667,14 @@ function noteSupplyWait(key, label, waitedMs) {
|
|
|
623
667
|
});
|
|
624
668
|
logger.info(
|
|
625
669
|
`supply "${label.slice(0, 40)}": a step must run at ${demand.requiredSpeed.toFixed(2)}x ` +
|
|
626
|
-
|
|
627
|
-
|
|
670
|
+
// "Interruption", not "wait": several readers walk one file — the picture
|
|
671
|
+
// and each audio rendition — so one missing piece produces one stall and
|
|
672
|
+
// several waits. Saying how many of each is what makes the figure readable;
|
|
673
|
+
// reporting the waits alone made `2 measured` look like two interruptions
|
|
674
|
+
// 3 ms apart, and the demanded speed came out at 4422x.
|
|
675
|
+
`to survive this swarm (worst stall ${demand.worstWaitSec.toFixed(2)}s, one every ` +
|
|
676
|
+
`${demand.medianIntervalSec.toFixed(2)}s of running, ${demand.samples} stall(s) ` +
|
|
677
|
+
`from ${demand.waits} wait(s)) — ` +
|
|
628
678
|
`and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s` +
|
|
629
679
|
// What steering the blocked piece onto another peer bought, as the
|
|
630
680
|
// difference between the waits where it placed something and the waits
|
|
@@ -890,6 +940,15 @@ export async function* readFragments({
|
|
|
890
940
|
}
|
|
891
941
|
|
|
892
942
|
const waitStartedAt = Date.now();
|
|
943
|
+
// What the WHOLE torrent received while this one piece was missing. It is
|
|
944
|
+
// the reading that separates the two causes a wait can have, and neither
|
|
945
|
+
// could be told from the other before: bytes arriving briskly throughout
|
|
946
|
+
// mean the swarm had capacity and this piece was stuck behind the wire
|
|
947
|
+
// that reserved it — the blocked-piece tail; bytes barely moving mean
|
|
948
|
+
// there was nothing to be had, and no reordering of requests would have
|
|
949
|
+
// helped. Asked of 2026-08-31, when a session with 4-5 peers of 38 known
|
|
950
|
+
// waited 41.32 s at worst and the log could not say which it was.
|
|
951
|
+
const downloadedAtWaitStart = Number(torrent?.downloaded) || 0;
|
|
893
952
|
// The reader is blocked, so this piece is now the only thing that matters
|
|
894
953
|
// on this torrent: hand it to the fastest wires that hold it. A block is
|
|
895
954
|
// reserved for exactly one wire, and the read ends when the slowest
|
|
@@ -955,9 +1014,16 @@ export async function* readFragments({
|
|
|
955
1014
|
// moment ago may now be the fastest one available.
|
|
956
1015
|
pushToFastest();
|
|
957
1016
|
}, 500);
|
|
1017
|
+
// Counted for exactly as long as this reader is inside the wait, so that
|
|
1018
|
+
// background work can stand aside while the viewer's own reading is
|
|
1019
|
+
// starving. The `finally` is what makes it safe: a cancelled or failed
|
|
1020
|
+
// read must not leave the torrent looking permanently blocked, which
|
|
1021
|
+
// would stop that background work for the rest of the session.
|
|
1022
|
+
countBlockedReader(torrent?.infoHash, 1);
|
|
958
1023
|
try {
|
|
959
1024
|
await whenPieceReady(torrent, pieceIndex, cancellation);
|
|
960
1025
|
} finally {
|
|
1026
|
+
countBlockedReader(torrent?.infoHash, -1);
|
|
961
1027
|
clearInterval(supplyProbe);
|
|
962
1028
|
}
|
|
963
1029
|
// What a reader spent waiting for data, attributed to the exact piece. A
|
|
@@ -999,11 +1065,21 @@ export async function* readFragments({
|
|
|
999
1065
|
}
|
|
1000
1066
|
if (waitedMs >= PIECE_WAIT_LOG_MS) {
|
|
1001
1067
|
const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
|
|
1068
|
+
// Two rates, side by side, and no verdict word between them: the swarm's
|
|
1069
|
+
// own delivery during the wait against what this piece managed. Both are
|
|
1070
|
+
// measured; which one a reader calls "the cause" follows from the pair
|
|
1071
|
+
// without a threshold having to be chosen here. Far apart means the
|
|
1072
|
+
// bytes were flowing and this piece was not among them; close together,
|
|
1073
|
+
// or both near zero, means there was nothing to deliver.
|
|
1074
|
+
const swarmBytes = Math.max(0, (Number(torrent?.downloaded) || 0) - downloadedAtWaitStart);
|
|
1075
|
+
const swarmKbps = Math.round(swarmBytes / 1024 / (waitedMs / 1000));
|
|
1002
1076
|
logger.info(
|
|
1003
1077
|
`piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
|
|
1004
1078
|
`(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
|
|
1005
1079
|
`${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}") ` +
|
|
1006
|
-
`— ${rateKbps}KB/s on this piece
|
|
1080
|
+
`— ${rateKbps}KB/s on this piece while the swarm delivered ` +
|
|
1081
|
+
`${swarmKbps}KB/s (${(swarmBytes / 1024 / 1024).toFixed(1)}MB) across the torrent, ` +
|
|
1082
|
+
`${Number(torrent?.numPeers) || 0} peers connected; ` +
|
|
1007
1083
|
(supply
|
|
1008
1084
|
? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
|
|
1009
1085
|
`${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
|
/**
|