@torrent-tv/proxy 2.80.12 → 2.80.14
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 +19 -0
- package/docs/download-architecture.md +65 -0
- package/docs/encode-architecture.md +52 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +64 -53
- package/routes/stream/get.js +324 -300
- package/routes/transcode/session-file/get.js +22 -2
- package/services/download/SwarmSelection.js +20 -4
- package/services/encode/SegmentDemand.js +34 -0
- package/services/encode/SegmentStore.js +743 -718
- package/services/hls-session-manager.js +22 -29
- package/services/orchestrators/EncodeOrchestrator.js +735 -726
- package/services/output/LiveOutputs.js +36 -0
- package/services/output/playlists.js +86 -11
- package/services/output/rates.js +95 -0
- package/services/piece-store/piece-lru.js +9 -1
- package/services/priority/PriorityOrchestrator.js +284 -278
- package/services/priority/WaitLedger.js +142 -0
- package/services/supply-margin.js +40 -1
- package/services/torrent-pool.js +38 -1
- package/services/torrent-worker/client.js +27 -12
- package/services/torrent-worker/piece-reader.js +9 -4
- package/services/torrent-worker/worker.js +5 -0
- package/services/viewer/Viewer.js +345 -297
- package/test/declared-rates.test.js +116 -0
- package/test/piece-lru.test.js +5 -1
- package/test/supply-margin.test.js +25 -8
- package/test/wait-ledger.test.js +100 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How well the priority map is being served, measured where somebody waits.
|
|
3
|
+
*
|
|
4
|
+
* The map says what matters most. Nothing said whether what mattered most was
|
|
5
|
+
* actually delivered first — so "is the prioritisation any good" had no answer
|
|
6
|
+
* of any kind, for either of the two things that read the map.
|
|
7
|
+
*
|
|
8
|
+
* The measure is the same for both, and it is a wait recorded against the rank
|
|
9
|
+
* the map gave the thing waited for AT THE MOMENT it was asked for. Read that
|
|
10
|
+
* way it says which part is wrong rather than whether the whole scheme is:
|
|
11
|
+
*
|
|
12
|
+
* - long waits at the TOP rank mean the urgent zone is not being served first,
|
|
13
|
+
* which is a fault in whoever acts on the map;
|
|
14
|
+
* - long waits further down with none at the top mean the zones are the wrong
|
|
15
|
+
* width — the urgent one too narrow, so the viewer reaches material that was
|
|
16
|
+
* only ever ranked "soon";
|
|
17
|
+
* - a rank with no waits at all is not a good sign or a bad one, it is silence,
|
|
18
|
+
* and it is reported as such rather than as a zero.
|
|
19
|
+
*
|
|
20
|
+
* There is nothing in here about encoders, torrents, sessions or viewers: it is
|
|
21
|
+
* given a rank and a number of milliseconds.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** How many waits to keep per rank. A median wants a sample, not a history. */
|
|
25
|
+
const HISTORY = 200;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ranks are collapsed into bands before they are counted.
|
|
29
|
+
*
|
|
30
|
+
* The map's ranks are as many as the film needs — 100 down to 1 on a long file
|
|
31
|
+
* — and a table with a hundred rows says nothing a reader can hold. What is
|
|
32
|
+
* being asked is coarse: was the thing waited for what the viewer needs NOW,
|
|
33
|
+
* what they reach shortly, or the rest of the film. So the top rank is its own
|
|
34
|
+
* band, the next few are the second, and everything below is the third.
|
|
35
|
+
*
|
|
36
|
+
* @param {number} rank
|
|
37
|
+
* @param {number} topRank - The highest rank the map currently states.
|
|
38
|
+
* @returns {"now" | "soon" | "later"}
|
|
39
|
+
*/
|
|
40
|
+
export function bandOf(rank, topRank) {
|
|
41
|
+
if (!Number.isFinite(rank) || rank <= 0) {
|
|
42
|
+
return "later";
|
|
43
|
+
}
|
|
44
|
+
const top = Number.isFinite(topRank) && topRank > 0 ? topRank : rank;
|
|
45
|
+
if (rank >= top) {
|
|
46
|
+
return "now";
|
|
47
|
+
}
|
|
48
|
+
// Within a tenth of the top: what a viewer reaches while watching what they
|
|
49
|
+
// hold. A tenth is the map's own shape — its zones widen geometrically — and
|
|
50
|
+
// not a threshold chosen for this table.
|
|
51
|
+
return rank >= top - Math.max(1, Math.round(top / 10)) ? "soon" : "later";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class WaitLedger {
|
|
55
|
+
/** @type {Map<string, Map<string, number[]>>} */
|
|
56
|
+
#waits = new Map();
|
|
57
|
+
|
|
58
|
+
/** @type {Map<string, Map<string, number>>} */
|
|
59
|
+
#counts = new Map();
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Somebody waited this long for something the map ranked this highly.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} key - What the waits belong to: an output, or a file.
|
|
65
|
+
* @param {number} waitedMs
|
|
66
|
+
* @param {number} rank - The map's rank for the thing waited for.
|
|
67
|
+
* @param {number} topRank - The highest rank the map states, so the rank can
|
|
68
|
+
* be read as a position rather than as an absolute number.
|
|
69
|
+
*/
|
|
70
|
+
note(key, waitedMs, rank, topRank) {
|
|
71
|
+
if (!key || !Number.isFinite(waitedMs) || waitedMs < 0) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const band = bandOf(rank, topRank);
|
|
75
|
+
let byBand = this.#waits.get(key);
|
|
76
|
+
if (!byBand) {
|
|
77
|
+
byBand = new Map();
|
|
78
|
+
this.#waits.set(key, byBand);
|
|
79
|
+
}
|
|
80
|
+
const held = byBand.get(band) ?? [];
|
|
81
|
+
held.push(waitedMs);
|
|
82
|
+
while (held.length > HISTORY) {
|
|
83
|
+
held.shift();
|
|
84
|
+
}
|
|
85
|
+
byBand.set(band, held);
|
|
86
|
+
|
|
87
|
+
let counts = this.#counts.get(key);
|
|
88
|
+
if (!counts) {
|
|
89
|
+
counts = new Map();
|
|
90
|
+
this.#counts.set(key, counts);
|
|
91
|
+
}
|
|
92
|
+
counts.set(band, (counts.get(band) ?? 0) + 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What the waits say, in one line, or null while nothing has waited.
|
|
97
|
+
*
|
|
98
|
+
* The count is the whole run and the median and worst are the recent sample,
|
|
99
|
+
* because those answer different questions: how often, and how badly.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} key
|
|
102
|
+
* @returns {string | null}
|
|
103
|
+
*/
|
|
104
|
+
describe(key) {
|
|
105
|
+
const byBand = this.#waits.get(key);
|
|
106
|
+
if (!byBand || byBand.size === 0) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const counts = this.#counts.get(key) ?? new Map();
|
|
110
|
+
const parts = [];
|
|
111
|
+
for (const band of ["now", "soon", "later"]) {
|
|
112
|
+
const held = byBand.get(band);
|
|
113
|
+
if (!held || held.length === 0) {
|
|
114
|
+
// Said out loud, because an absent band and a band that never waited
|
|
115
|
+
// are the same silence and neither is a zero.
|
|
116
|
+
parts.push(`${band} none`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const sorted = [...held].sort((a, b) => a - b);
|
|
120
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
121
|
+
parts.push(
|
|
122
|
+
`${band} ${counts.get(band) ?? held.length} wait(s) median ${Math.round(median)}ms ` +
|
|
123
|
+
`worst ${Math.round(sorted[sorted.length - 1])}ms`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return parts.join(", ");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} key
|
|
131
|
+
*/
|
|
132
|
+
forget(key) {
|
|
133
|
+
this.#waits.delete(key);
|
|
134
|
+
this.#counts.delete(key);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One ledger for the process, because the question is asked in two layers and
|
|
140
|
+
* the answer is comparable only if the scale is the same.
|
|
141
|
+
*/
|
|
142
|
+
export const waits = new WaitLedger();
|
|
@@ -110,8 +110,47 @@ export function requiredSpeedFrom(waits) {
|
|
|
110
110
|
if (!(medianIntervalSec > 0)) {
|
|
111
111
|
return null;
|
|
112
112
|
}
|
|
113
|
+
// THE SHARE OF ITS TIME THE READING LOST, over the stretch actually observed.
|
|
114
|
+
//
|
|
115
|
+
// The model is unchanged and it was always right: if a fraction `f` of the
|
|
116
|
+
// time is spent not delivering, then producing one second of film takes
|
|
117
|
+
// `1/(1 - f)` seconds, so a step must run that much faster than realtime.
|
|
118
|
+
// What was wrong was the two quantities fed into it — the WORST single
|
|
119
|
+
// interruption divided by the MEDIAN gap between interruptions, a maximum
|
|
120
|
+
// over a median, from two populations that need not be the same events at
|
|
121
|
+
// all. It asks what would happen if the worst interruption recurred at the
|
|
122
|
+
// typical rate, which is a compound case that never occurs, and it divides by
|
|
123
|
+
// a gap that goes to zero whenever interruptions arrive in a burst.
|
|
124
|
+
//
|
|
125
|
+
// Field 2026-09-08: 0.79 s (one jump, on a file already downloaded whole)
|
|
126
|
+
// over 0.01 s (the gaps inside a burst of microsecond waits) gave 158.60x,
|
|
127
|
+
// and the quality budget refused every step against it forty times in one
|
|
128
|
+
// session. The same measurements as a share of time lost give 1.00x, because
|
|
129
|
+
// that is what a reading of a complete file loses.
|
|
130
|
+
//
|
|
131
|
+
// The denominator here cannot vanish: it is the span the interruptions are
|
|
132
|
+
// spread over, which contains them.
|
|
133
|
+
// OVER WHOLE CYCLES, from the first interruption's start to the last one's
|
|
134
|
+
// start. That window holds exactly one running stretch per interruption in
|
|
135
|
+
// it, so the share does not depend on where the window happens to be cut —
|
|
136
|
+
// measured to the last interruption's END it counts one interruption more
|
|
137
|
+
// than it counts running stretches, and the same supply then reads 1.54x or
|
|
138
|
+
// 1.45x according to nothing but the moment the log line was printed.
|
|
139
|
+
const last = interruptions.length - 1;
|
|
140
|
+
const spanSec = (interruptions[last].start - interruptions[0].start) / 1000;
|
|
141
|
+
const lostSec = interruptions
|
|
142
|
+
.slice(0, last)
|
|
143
|
+
.reduce((total, one) => total + (one.end - one.start), 0) / 1000;
|
|
144
|
+
// A span that is all interruption says the supply delivered nothing at all
|
|
145
|
+
// while it was watched. There is no speed that survives that, and saying a
|
|
146
|
+
// huge number is less honest than saying it is not a speed question — so the
|
|
147
|
+
// largest figure any step is ever compared against is stated and named.
|
|
148
|
+
const lostShare = spanSec > 0 ? Math.min(0.99, lostSec / spanSec) : 0;
|
|
113
149
|
return {
|
|
114
|
-
requiredSpeed: 1
|
|
150
|
+
requiredSpeed: 1 / (1 - lostShare),
|
|
151
|
+
lostShare,
|
|
152
|
+
spanSec,
|
|
153
|
+
lostSec,
|
|
115
154
|
worstWaitSec,
|
|
116
155
|
medianIntervalSec,
|
|
117
156
|
// Interruptions, not waits: what the figure is derived from. The two differ
|
package/services/torrent-pool.js
CHANGED
|
@@ -15,7 +15,7 @@ import { rmSync, statfsSync } from "node:fs";
|
|
|
15
15
|
import WebTorrent from "webtorrent";
|
|
16
16
|
import { logger } from "../utils/logger.js";
|
|
17
17
|
import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
|
|
18
|
-
import { Urgency } from "./demand/index.js";
|
|
18
|
+
import { Urgency, urgencyName } from "./demand/index.js";
|
|
19
19
|
import { demandFor, forgetTorrent, reconcileAll } from "./download/registry.js";
|
|
20
20
|
import { isAtAWatchingViewer, isBehindEverybody, isNobodyComingNow } from "./priority/PriorityMap.js";
|
|
21
21
|
import { deriveSourceKey } from "./torrent-source-key.js";
|
|
@@ -682,6 +682,12 @@ export function dhtNodeCount(client) {
|
|
|
682
682
|
}
|
|
683
683
|
}
|
|
684
684
|
|
|
685
|
+
// The last shape said out loud per torrent, so an unchanged one is not
|
|
686
|
+
// repeated. Held here rather than on the pool because `applyPriorityMap`
|
|
687
|
+
// reads nothing but its arguments — which is what lets it be exercised
|
|
688
|
+
// without building a pool, a torrent client or a thread.
|
|
689
|
+
const lastMapSaid = new WeakMap();
|
|
690
|
+
|
|
685
691
|
export class TorrentPool {
|
|
686
692
|
/**
|
|
687
693
|
* In-flight `client.add()` promises keyed by the same key as `torrents`.
|
|
@@ -1099,6 +1105,37 @@ export class TorrentPool {
|
|
|
1099
1105
|
register.withdraw(window.claimant);
|
|
1100
1106
|
}
|
|
1101
1107
|
}
|
|
1108
|
+
// WHAT THE SWARM WAS ACTUALLY TOLD, said on change and never on a timer.
|
|
1109
|
+
// The map was applied in silence: that it had been BUILT was visible in the
|
|
1110
|
+
// encoding's own line, and that the download had received it was visible
|
|
1111
|
+
// nowhere at all — so "is the map reaching the swarm" could only be taken
|
|
1112
|
+
// on trust. Field 2026-09-08: not one line about it in a whole session.
|
|
1113
|
+
//
|
|
1114
|
+
// Per LEVEL rather than per zone, because the register has five levels and
|
|
1115
|
+
// the map has as many bands as the film needs; the fit between them is the
|
|
1116
|
+
// one thing here that could be wrong, and this is what shows it. Megabytes,
|
|
1117
|
+
// because that is what a swarm delivers.
|
|
1118
|
+
const byLevel = new Map();
|
|
1119
|
+
for (const zone of ordered) {
|
|
1120
|
+
const level = levelOf(zone);
|
|
1121
|
+
const seconds = Math.max(0, zone.to - zone.from);
|
|
1122
|
+
const held = byLevel.get(level) ?? { zones: 0, megabytes: 0 };
|
|
1123
|
+
held.zones += 1;
|
|
1124
|
+
held.megabytes += (seconds / duration) * length / 1048576;
|
|
1125
|
+
byLevel.set(level, held);
|
|
1126
|
+
}
|
|
1127
|
+
const shape = [...byLevel.entries()]
|
|
1128
|
+
.sort((left, right) => right[0] - left[0])
|
|
1129
|
+
.map(([level, held]) => `${urgencyName(level)} ${held.zones} zone(s) ${held.megabytes.toFixed(0)}MB`)
|
|
1130
|
+
.join(", ");
|
|
1131
|
+
const said = `${fileIndex}:${shape}`;
|
|
1132
|
+
if (lastMapSaid.get(torrent) !== said) {
|
|
1133
|
+
lastMapSaid.set(torrent, said);
|
|
1134
|
+
logger.info(
|
|
1135
|
+
`torrent-pool: the swarm is told, for "${file.name}": ${shape || "nothing"} ` +
|
|
1136
|
+
`(${ordered.length} band(s) of the map, over ${Math.round(duration)}s of film)`
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1102
1139
|
}
|
|
1103
1140
|
|
|
1104
1141
|
#reportStalledDownloads() {
|
|
@@ -228,12 +228,7 @@ export class TorrentWorkerClient {
|
|
|
228
228
|
// Fail everything outstanding rather than leaving callers hanging: a dead
|
|
229
229
|
// worker will never answer, and a stalled request is worse than an error
|
|
230
230
|
// the loading flow can retry.
|
|
231
|
-
|
|
232
|
-
this.#caller.rejectAll(reason);
|
|
233
|
-
for (const [, read] of this.#reads) {
|
|
234
|
-
read.fail(reason);
|
|
235
|
-
}
|
|
236
|
-
this.#reads.clear();
|
|
231
|
+
this.#failEverythingOutstanding(new Error("Torrent worker stopped unexpectedly."));
|
|
237
232
|
});
|
|
238
233
|
|
|
239
234
|
// A worker that ENDS was, until now, not noticed at all: only `message` and
|
|
@@ -252,15 +247,35 @@ export class TorrentWorkerClient {
|
|
|
252
247
|
`torrent-worker: thread ended on its own with code ${code} — nobody asked it to. ` +
|
|
253
248
|
"Everything waiting on it is failed; the proxy has no torrent client until it is rebuilt."
|
|
254
249
|
);
|
|
255
|
-
|
|
256
|
-
this.#caller.rejectAll(reason);
|
|
257
|
-
for (const [, read] of this.#reads) {
|
|
258
|
-
read.fail(reason);
|
|
259
|
-
}
|
|
260
|
-
this.#reads.clear();
|
|
250
|
+
this.#failEverythingOutstanding(new Error("Torrent worker ended unexpectedly."));
|
|
261
251
|
});
|
|
262
252
|
}
|
|
263
253
|
|
|
254
|
+
/**
|
|
255
|
+
* A dead thread will never answer, so everything waiting on it is failed.
|
|
256
|
+
*
|
|
257
|
+
* INCLUDING THE FRAGMENT READS, which is what ffmpeg's input is. Both death
|
|
258
|
+
* handlers walked `#reads` alone and left `#fragmentReaders` untouched, so an
|
|
259
|
+
* encoder's input neither ended nor errored — it went quiet. Field 2026-08-31,
|
|
260
|
+
* three times: the thread died, both ffmpeg runs stayed ALIVE and stopped
|
|
261
|
+
* producing (167 `holding segment-00085.mp4 … encoder alive` lines), the
|
|
262
|
+
* browser drained its cushion and then retried a segment that would never
|
|
263
|
+
* exist, for ever, with nothing shown to the viewer.
|
|
264
|
+
*
|
|
265
|
+
* @param {Error} reason
|
|
266
|
+
*/
|
|
267
|
+
#failEverythingOutstanding(reason) {
|
|
268
|
+
this.#caller.rejectAll(reason);
|
|
269
|
+
for (const [, read] of this.#reads) {
|
|
270
|
+
read.fail(reason);
|
|
271
|
+
}
|
|
272
|
+
this.#reads.clear();
|
|
273
|
+
for (const [, reader] of this.#fragmentReaders) {
|
|
274
|
+
reader.fail(reason);
|
|
275
|
+
}
|
|
276
|
+
this.#fragmentReaders.clear();
|
|
277
|
+
}
|
|
278
|
+
|
|
264
279
|
/**
|
|
265
280
|
* Add (or join) a torrent and register it under `sourceKey`.
|
|
266
281
|
*
|
|
@@ -243,7 +243,7 @@ const supplyReportedAt = new Map();
|
|
|
243
243
|
* Record one interruption and, at most twice a minute, say what it implies.
|
|
244
244
|
*
|
|
245
245
|
* The two figures are the whole of roadmap item 3: the speed a step must
|
|
246
|
-
* sustain to survive this supply (`1
|
|
246
|
+
* sustain to survive this supply (`1 / (1 - the share of time lost)`), and the
|
|
247
247
|
* smallest buffer that hides an interruption from the viewer. Both are printed
|
|
248
248
|
* before either is USED, so the field says whether the arithmetic describes
|
|
249
249
|
* reality before anything is decided by it.
|
|
@@ -265,7 +265,7 @@ const supplyReportedAt = new Map();
|
|
|
265
265
|
* @param {string} infoHash
|
|
266
266
|
* @param {string} fileName
|
|
267
267
|
* @param {number} segmentSeconds - The session's own segment duration.
|
|
268
|
-
* @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, samples: number, minimumBufferSec: number } | null}
|
|
268
|
+
* @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, lostShare: number, spanSec: number, lostSec: number, samples: number, minimumBufferSec: number } | null}
|
|
269
269
|
*/
|
|
270
270
|
export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
|
|
271
271
|
const history = supplyWaits.get(`${infoHash ?? "?"}/${fileName ?? "?"}`);
|
|
@@ -502,8 +502,13 @@ function noteSupplyWait(key, label, waitedMs) {
|
|
|
502
502
|
// several waits. Saying how many of each is what makes the figure readable;
|
|
503
503
|
// reporting the waits alone made `2 measured` look like two interruptions
|
|
504
504
|
// 3 ms apart, and the demanded speed came out at 4422x.
|
|
505
|
-
|
|
506
|
-
|
|
505
|
+
// THE NUMBERS THE FIGURE IS MADE OF, so a wrong one can be seen to be wrong.
|
|
506
|
+
// The share of time lost is what the speed now comes from; the worst stall
|
|
507
|
+
// and the typical gap are printed beside it because they are what the
|
|
508
|
+
// cushion is sized by and what the old formula divided one by the other.
|
|
509
|
+
`to survive this swarm (lost ${demand.lostSec.toFixed(2)}s of ${demand.spanSec.toFixed(2)}s ` +
|
|
510
|
+
`= ${(demand.lostShare * 100).toFixed(1)}%, worst stall ${demand.worstWaitSec.toFixed(2)}s, ` +
|
|
511
|
+
`one every ${demand.medianIntervalSec.toFixed(2)}s of running, ${demand.samples} stall(s) ` +
|
|
507
512
|
`from ${demand.waits} wait(s)) — ` +
|
|
508
513
|
`and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s` +
|
|
509
514
|
// What steering the blocked piece onto another peer bought, as the
|
|
@@ -747,6 +747,11 @@ setInterval(() => {
|
|
|
747
747
|
`piece-store "${stats.name.slice(0, 40)}" demand: ${demand.readers} reader(s) want ` +
|
|
748
748
|
`${demand.unionPieces} piece(s) of ${demand.capacity} the store may hold ` +
|
|
749
749
|
`(widest window ${demand.widestPieces})` +
|
|
750
|
+
// NAMED, because the count read as five encoders on a session that had
|
|
751
|
+
// two: a "reader" is whoever declared a range, and four of the five
|
|
752
|
+
// were zones of the priority map. Choosing between narrowing the
|
|
753
|
+
// windows and raising the allowance was guesswork without this.
|
|
754
|
+
` [${demand.names.join(" ")}]` +
|
|
750
755
|
(stats.evictedProtected > 0
|
|
751
756
|
? `; ${stats.evictedProtected} of ${stats.spills} eviction(s) took a piece a reader had declared`
|
|
752
757
|
: "; no eviction has taken a declared piece") +
|