@torrent-tv/proxy 2.80.13 → 2.80.15

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.
@@ -53,9 +53,22 @@ export class RunCosts {
53
53
  * that was never told to stop did not die on command, and one that produced
54
54
  * nothing has no first output — and an absent reading is not a zero.
55
55
  *
56
- * @param {{ dyingMs?: number | null, firstOutputMs?: number | null }} ended
56
+ * @param {{ dyingMs?: number | null, firstOutputMs?: number | null,
57
+ * livedMs?: number | null }} ended - `livedMs` is how long a run that
58
+ * produced NOTHING was alive, which is a lower bound on the first output.
57
59
  */
58
60
  note(ended) {
61
+ // A RUN THAT PRODUCED NOTHING IS A MEASUREMENT TOO — of a lower bound. It
62
+ // says the first output takes at least as long as this run lived, which is
63
+ // a fact and not an estimate, and it is the only reading a thrash can
64
+ // supply: every run in one is killed before it finishes anything.
65
+ //
66
+ // Without it the figure that prices a move could only ever be learned from
67
+ // runs that survived, so the state in which moves are ruinous was exactly
68
+ // the state in which their cost stayed unknown.
69
+ if (!Number.isFinite(ended?.firstOutputMs) && Number.isFinite(ended?.livedMs) && ended.livedMs > 0) {
70
+ RunCosts.#keep(this.#firstOutput, /** @type {number} */ (ended.livedMs));
71
+ }
59
72
  if (Number.isFinite(ended?.dyingMs)) {
60
73
  RunCosts.#keep(this.#dying, /** @type {number} */ (ended.dyingMs));
61
74
  }
@@ -85,9 +98,47 @@ export class RunCosts {
85
98
  * @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
86
99
  */
87
100
  seconds() {
101
+ const dying = middleOf(this.#dying);
102
+ const first = middleOf(this.#firstOutput);
88
103
  return {
89
- killCostSec: (middleOf(this.#dying) ?? 0) / 1000,
90
- firstByteWaitSec: (middleOf(this.#firstOutput) ?? 0) / 1000,
104
+ // UNKNOWN IS NOT ZERO, and for a cost it is not a small number either: it
105
+ // is the figure that makes the act it prices never worth doing. Reported
106
+ // as 0, an unmeasured move was FREE in the plan's arithmetic, so any gain
107
+ // however small justified it — and moving an encoder is irreversible,
108
+ // because the process it kills cannot be un-killed.
109
+ //
110
+ // The blindness was self-sustaining: `#firstOutput` only takes a reading
111
+ // from a run that produced something, and a run killed 0.8 s after
112
+ // starting produces nothing. So a thrash prevented the measurement that
113
+ // would have stopped it. Field 2026-09-08: 39 moves in one session, 24 of
114
+ // them between three adjacent numbers — #58 to #59, #59 to #58, #58 to
115
+ // #60, #60 to #58, six times each — while the picture stood still for
116
+ // 116.7 s.
117
+ //
118
+ // TWO QUESTIONS, NOT ONE, and they take the unknown differently.
119
+ //
120
+ // PLACING an encoder where there is none has no alternative: the film gets
121
+ // made or it does not. So an unmeasured cost must not stand in the way,
122
+ // and the honest figure is what has been measured or nothing.
123
+ //
124
+ // MOVING one has an alternative — leave it alone — and it is
125
+ // irreversible, because the process it kills cannot be un-killed. There
126
+ // an unmeasured cost must not license the act, and `Infinity` is the
127
+ // identity of the comparison that consumes it: "nobody has measured what
128
+ // this costs" and "never worth doing" are the same statement about an
129
+ // action whose price is unknown.
130
+ //
131
+ // Reported as 0 for both, an unmeasured move was FREE in the plan's
132
+ // arithmetic, so a gain of a fraction of a second justified it. And the
133
+ // blindness was self-sustaining: `#firstOutput` takes a reading only from
134
+ // a run that produced something, and every run in a thrash is killed
135
+ // before it finishes anything.
136
+ killCostSec: (dying ?? 0) / 1000,
137
+ firstByteWaitSec: (first ?? 0) / 1000,
138
+ moveCostSec:
139
+ first === null
140
+ ? Number.POSITIVE_INFINITY
141
+ : ((dying ?? 0) + first) / 1000,
91
142
  samples: Math.min(this.#dying.length, this.#firstOutput.length)
92
143
  };
93
144
  }
@@ -1510,7 +1510,11 @@ export class HlsSessionManager {
1510
1510
  // picture a step belongs to, the steps, the soundtracks, the height a
1511
1511
  // session is named by. Read-only over the register above, and the layer the
1512
1512
  // quality budget and the serving path both stand on.
1513
- this.liveOutputs = new LiveOutputs({ sessionsById: this.sessionsById });
1513
+ this.liveOutputs = new LiveOutputs({
1514
+ sessionsById: this.sessionsById,
1515
+ fileLengthOf: (session) => this.#fileLengthByKey.get(session.file.key) ?? 0,
1516
+ largestPieceOf: (address) => this.segmentStore.largestPiece(address)
1517
+ });
1514
1518
  // What this host learned last time it ran. Without it every restart shows
1515
1519
  // the first viewer a figure with no measurement behind it.
1516
1520
  this.#loadHostTimings();
@@ -3178,7 +3182,7 @@ export class HlsSessionManager {
3178
3182
  * @param {{ linkMbps: number, bufferedAheadSec: number, consumerId?: string, positionSeconds?: number }} report
3179
3183
  * @returns {boolean}
3180
3184
  */
3181
- recordNetReport(sessionId, { linkMbps, bufferedAheadSec, consumerId, positionSeconds, playing }) {
3185
+ recordNetReport(sessionId, { linkMbps, bufferedAheadSec, consumerId, positionSeconds, playing, onScreen, inPictureInPicture }) {
3182
3186
  const named = this.sessionsById.get(sessionId);
3183
3187
  if (!named || named.state === "disposed") {
3184
3188
  return false;
@@ -3196,7 +3200,7 @@ export class HlsSessionManager {
3196
3200
  const now = Date.now();
3197
3201
  this.viewers
3198
3202
  .of(session, typeof consumerId === "string" && consumerId.length > 0 ? consumerId : "")
3199
- .report({ linkMbps, bufferedAheadSec, positionSeconds, playing }, now);
3203
+ .report({ linkMbps, bufferedAheadSec, positionSeconds, playing, onScreen, inPictureInPicture }, now);
3200
3204
  // A stale reading must not go on deciding for the viewers still here: a
3201
3205
  // report describes a link at a moment, and a viewer who seeked since then
3202
3206
  // is somewhere else entirely.
@@ -8295,13 +8299,6 @@ export class HlsSessionManager {
8295
8299
  if (!this.liveOutputs.publishesVariants(session)) {
8296
8300
  return null;
8297
8301
  }
8298
- const sourceHeight = Number(session.file.height) || 0;
8299
- // What CAN be spliced, not what is worth offering this second. The live
8300
- // judgement travels in `offeredHeights` and in every progress report, which
8301
- // is what the viewer's menu follows; letting it decide the master's
8302
- // existence made a live session answer 404 to its own published address.
8303
- const rungs = this.liveOutputs.splicableHeights(session);
8304
- const sourceWidth = Number(session.file.width) || 0;
8305
8302
  // The audio tracks, published once for the whole file rather than muxed
8306
8303
  // into every rung. Two things follow from that: the same track is not
8307
8304
  // encoded once per rung on a host that struggles to encode it once, and
@@ -8319,10 +8316,12 @@ export class HlsSessionManager {
8319
8316
  ? this.#audioRenditionsOf(session, this.#audioChoiceOf(session, consumerId).trackIndex)
8320
8317
  : [];
8321
8318
  return masterPlaylistText({
8322
- playlistVersion: session.segmentFormat.playlistVersion,
8323
- heights: rungs,
8324
- sourceWidth,
8325
- sourceHeight,
8319
+ // The shape of the film and the rates it carries, asked of the layer that
8320
+ // holds both. What CAN be spliced, not what is worth offering this second:
8321
+ // the live judgement travels in `offeredHeights` and in every progress
8322
+ // report, and letting it decide the master's existence made a live session
8323
+ // answer 404 to its own published address.
8324
+ ...this.liveOutputs.masterFactsOf(session),
8326
8325
  renditions,
8327
8326
  playlistFileName: PLAYLIST_FILE_NAME
8328
8327
  });
@@ -16,14 +16,22 @@
16
16
 
17
17
  import { variantHeightsFor } from "./ladder.js";
18
18
 
19
+ import { masterRateArgs } from "./rates.js";
20
+
19
21
  export class LiveOutputs {
20
22
  /**
21
23
  * @param {object} params
22
24
  * @param {Map<string, object>} params.sessionsById - The live sessions. Read,
23
25
  * never written.
24
26
  */
25
- constructor({ sessionsById }) {
27
+ constructor({ sessionsById, fileLengthOf = () => 0, largestPieceOf = () => ({ index: -1, size: 0 }) }) {
26
28
  this.sessionsById = sessionsById;
29
+ // Two facts this layer needs and does not own: how many bytes a source file
30
+ // is, which the torrent reports, and the biggest piece an output has made,
31
+ // which the disk knows. Taken as plain functions, so nothing of either layer
32
+ // is held here.
33
+ this.fileLengthOf = fileLengthOf;
34
+ this.largestPieceOf = largestPieceOf;
27
35
  }
28
36
 
29
37
  /**
@@ -264,4 +272,42 @@ export class LiveOutputs {
264
272
  }
265
273
  return this.splicableHeights(owner).length >= 2;
266
274
  }
275
+ /**
276
+ * Everything the master playlist needs of a session except its soundtracks.
277
+ *
278
+ * The shape of the film — which heights can be spliced, how big the source
279
+ * is, how the pieces are packaged — and the rates it carries. All of it is a
280
+ * fact about the OUTPUT, and it was assembled at the call site in the session
281
+ * manager, which is the file that is being taken apart.
282
+ *
283
+ * The soundtracks are not here on purpose: which of them is marked default is
284
+ * the asking VIEWER'S business, and that belongs to whoever holds viewers.
285
+ *
286
+ * @param {object} session
287
+ * @returns {object}
288
+ */
289
+ masterFactsOf(session) {
290
+ return {
291
+ playlistVersion: session.segmentFormat.playlistVersion,
292
+ heights: this.splicableHeights(session),
293
+ sourceWidth: Number(session.file?.width) || 0,
294
+ sourceHeight: Number(session.file?.height) || 0,
295
+ ...masterRateArgs({
296
+ // FROM WHOEVER KNOWS IT. A source file does not carry its own byte
297
+ // length — the torrent reports it — and reading a field that does not
298
+ // exist is what declared every variant at the floor in 2.80.14.
299
+ fileLength: Number(this.fileLengthOf(session)) || 0,
300
+ durationSeconds: Number(session.file?.durationSeconds) || 0,
301
+ // The probe's own reading of the video stream, which is known from the
302
+ // moment the session exists and covers the gap before the torrent has
303
+ // reported a length.
304
+ streamBitsPerSecond: (Number(session.file?.decode?.megabitsPerSecond) || 0) * 1_000_000,
305
+ largest: this.largestPieceOf(session.outputKey ?? ""),
306
+ boundaries: session.timeline?.published ?? session.timeline?.boundaries ?? null,
307
+ producedHeight: this.producedHeightOf(session),
308
+ capKbps: Number(session.rateCapKbps) || 0
309
+ })
310
+ };
311
+ }
312
+
267
313
  }
@@ -58,19 +58,75 @@ export function escapeAttribute(value) {
58
58
  return String(value ?? "").replace(/"/g, "'").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
59
59
  }
60
60
 
61
+ // The smallest rate the HLS specification tolerates in a `BANDWIDTH` attribute.
62
+ // It is a floor on what may be DECLARED, not a belief about any content: a file
63
+ // whose rate is not yet known is described by it, and so is a variant so small
64
+ // that the arithmetic below would go under it.
65
+ const MINIMUM_DECLARED_BITS_PER_SECOND = 400_000;
66
+
61
67
  /**
62
- * A rough bitrate for a height, in bits per second.
68
+ * How many bits of film there are per second of playback, for one height.
69
+ *
70
+ * MEASURED, and the measurement is available before anything is encoded. The
71
+ * comment here used to say the opposite — "a measurement we do not have before
72
+ * encoding starts" — and gave `height * height * 3.2` instead, which for 1080
73
+ * is 3 732 480. Field 2026-09-08: that was declared for a file carrying
74
+ * 18.4 Mbit/s, five times more, and the arithmetic that reads it is not
75
+ * cosmetic.
76
+ *
77
+ * **What reads it.** The browser sizes its cushion in BYTES from this figure
78
+ * times the seconds it is asked to hold, so a figure five times low makes the
79
+ * cushion five times shallow: 120 s asked bought 56 MB, which is 26 s of that
80
+ * film, and the deepest the browser ever held was 17.1 s. And hls.js compares
81
+ * it against its own estimate of the link to decide a level is unplayable —
82
+ * which is why an inflated figure is not the answer either: its own recovery
83
+ * then moves level, and that path does not honour our pinning (measured, 2.59.3).
84
+ *
85
+ * **Where the figure comes from.** Two cases, both exact:
86
+ *
87
+ * - a height that is COPIED carries the source's own bits, so it is the file's
88
+ * length over its duration;
89
+ * - a height that is RE-ENCODED carries what the encoder is capped at, which we
90
+ * impose ourselves.
63
91
  *
64
- * `BANDWIDTH` is required on every variant by the HLS specification, and the
65
- * player uses it to order them. It does not have to be exact — nothing here
66
- * adapts on it, because the viewer chooses so it is the usual H.264 rule of
67
- * thumb rather than a measurement we do not have before encoding starts.
92
+ * The specification wants the PEAK per segment in `BANDWIDTH` and the average
93
+ * in `AVERAGE-BANDWIDTH`; both are emitted, and the peak is scaled from the
94
+ * average by the ratio the largest produced segment has actually shown never
95
+ * a chosen multiplier, and equal to the average until a segment exists.
68
96
  *
69
- * @param {number} height
97
+ * @param {object} params
98
+ * @param {number} params.averageBitsPerSecond - The film's own rate, measured.
99
+ * @param {number} params.height
100
+ * @param {number} params.sourceHeight
101
+ * @param {number} [params.capKbps] - What a re-encoded height is capped at.
70
102
  * @returns {number}
71
103
  */
72
- export function estimatedBitrateFor(height) {
73
- return Math.max(400_000, Math.round(height * height * 3.2));
104
+ export function bitrateFor({ averageBitsPerSecond, height, sourceHeight, capKbps = 0 }) {
105
+ // ONE EXPRESSION, and each term is a measured quantity or the absence of one
106
+ // written as the identity of its operation. There is no case analysis here
107
+ // because there are no cases: the rate a variant carries is what the source
108
+ // carries, shrunk by how much less picture there is, and never more than what
109
+ // we cap the encoder at.
110
+ //
111
+ // - what the source carries: `length * 8 / duration`, exact. Unknown is 0,
112
+ // and 0 falls to the floor below, which is what "not measured" means;
113
+ // - how much less picture: the ratio of pixel counts, and never above 1 —
114
+ // a variant at or above the source's height carries the source's bits.
115
+ // The pixel count is the one term of the relation that is a fact rather
116
+ // than an opinion about the encoder, and it errs HIGH for a small height,
117
+ // which is the safe direction: the cushion is sized generously and the
118
+ // player does not conclude the level is beyond its link;
119
+ // - what we cap it at: exact where we impose one, and `Infinity` where we
120
+ // do not, which is the identity of `min` and so states "no cap" without a
121
+ // branch;
122
+ // - the floor: the smallest figure the specification tolerates, and the
123
+ // identity of `max`.
124
+ const measured = Number(averageBitsPerSecond) > 0 ? Number(averageBitsPerSecond) : 0;
125
+ const shrink = height > 0 && sourceHeight > 0
126
+ ? Math.min(1, (height * height) / (sourceHeight * sourceHeight))
127
+ : 1;
128
+ const cap = capKbps > 0 ? capKbps * 1000 : Number.POSITIVE_INFINITY;
129
+ return Math.round(Math.max(MINIMUM_DECLARED_BITS_PER_SECOND, Math.min(measured * shrink, cap)));
74
130
  }
75
131
 
76
132
  /**
@@ -134,7 +190,10 @@ export function mediaPlaylistText({ boundaries, segmentFormat }) {
134
190
  * sourceWidth: number,
135
191
  * sourceHeight: number,
136
192
  * renditions?: Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>,
137
- * playlistFileName: string
193
+ * playlistFileName: string,
194
+ * averageBitsPerSecond?: number,
195
+ * peakOverAverage?: number,
196
+ * capKbpsFor?: (height: number) => number
138
197
  * }} params
139
198
  * @returns {string}
140
199
  */
@@ -144,7 +203,10 @@ export function masterPlaylistText({
144
203
  sourceWidth,
145
204
  sourceHeight,
146
205
  renditions = [],
147
- playlistFileName
206
+ playlistFileName,
207
+ averageBitsPerSecond = 0,
208
+ peakOverAverage = 1,
209
+ capKbpsFor = () => 0
148
210
  }) {
149
211
  const lines = ["#EXTM3U", `#EXT-X-VERSION:${playlistVersion}`];
150
212
  const audioGroup = renditions.length > 0 ? AUDIO_GROUP_ID : "";
@@ -160,8 +222,21 @@ export function masterPlaylistText({
160
222
  const width = sourceHeight > 0 && sourceWidth > 0
161
223
  ? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
162
224
  : 0;
225
+ const average = bitrateFor({
226
+ averageBitsPerSecond,
227
+ height,
228
+ sourceHeight,
229
+ capKbps: capKbpsFor(height)
230
+ });
231
+ // BOTH, because they answer different questions and the specification has a
232
+ // name for each: the peak is what a link must carry at the worst moment,
233
+ // the average is what the whole variant costs. hls.js sizes its byte budget
234
+ // from BANDWIDTH, so the peak is what stops the cushion being sized for a
235
+ // quiet stretch and running dry on a loud one — measured on the field file,
236
+ // 17.1 Mbit/s median against 73 Mbit/s at its peak.
237
+ const peak = Math.round(average * Math.max(1, peakOverAverage));
163
238
  lines.push(
164
- `#EXT-X-STREAM-INF:BANDWIDTH=${estimatedBitrateFor(height)}` +
239
+ `#EXT-X-STREAM-INF:BANDWIDTH=${peak},AVERAGE-BANDWIDTH=${average}` +
165
240
  (width > 0 ? `,RESOLUTION=${width}x${height}` : "") +
166
241
  (audioGroup ? `,AUDIO="${audioGroup}"` : "")
167
242
  );
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file How many bits of film there are per second of playback.
3
+ *
4
+ * A fact about the OUTPUT, and the two figures the HLS specification asks for:
5
+ * the average over the whole variant, and the peak one segment reaches. Both
6
+ * are measured — the average from the file's own length and duration, the peak
7
+ * from the biggest piece actually produced and the span it covers.
8
+ *
9
+ * It used to be `height * height * 3.2`, a rule of thumb, justified by a comment
10
+ * saying the measurement was not available before encoding starts. It is: the
11
+ * length and the duration are known when the session is created, and the peak
12
+ * refines itself as pieces appear. Field 2026-09-08: 3.73 Mbit/s declared for a
13
+ * file carrying 18.4, and the browser's cushion is sized in BYTES from that
14
+ * figure — 120 s asked bought 26 s of film.
15
+ *
16
+ * Pure: plain numbers in, plain numbers out. Nothing here knows what a session,
17
+ * a store or a torrent is.
18
+ */
19
+
20
+ /**
21
+ * The two rates to declare, from what has been measured of this file.
22
+ *
23
+ * @param {object} params
24
+ * @param {number} params.fileLength - Bytes of the source file, 0 until the
25
+ * torrent has said. Counts every track and every byte of container, so it is
26
+ * the better figure of the two.
27
+ * @param {number} params.durationSeconds
28
+ * @param {number} [params.streamBitsPerSecond] - What a probe read off the
29
+ * video stream, known from session creation. Lower than the whole file's rate
30
+ * because it is one track of it, and used while the length is not known.
31
+ * @param {{ index: number, size: number }} [params.largest] - The biggest piece
32
+ * produced so far, with its number. An index of `-1` means none yet.
33
+ * @param {number[]} [params.boundaries] - Where the file is cut, so the biggest
34
+ * piece's own span is known. Bytes alone cannot give a rate.
35
+ * @returns {{ averageBitsPerSecond: number, peakOverAverage: number }}
36
+ */
37
+ export function declaredRates({
38
+ fileLength,
39
+ durationSeconds,
40
+ streamBitsPerSecond = 0,
41
+ largest = null,
42
+ boundaries = null
43
+ }) {
44
+ const length = Number(fileLength);
45
+ const duration = Number(durationSeconds);
46
+ // TWO SOURCES, AND THE LARGER OF THEM, because each is measured and each can
47
+ // be absent — and absent is 0, so the larger is whichever was measured.
48
+ //
49
+ // - the whole file's length over its duration: the best figure, since it
50
+ // counts every track and every byte of container. Known only once the
51
+ // torrent has reported the file's size;
52
+ // - what the probe read off the video stream: known when the session is
53
+ // created, and lower, because it is one track of several.
54
+ //
55
+ // The first alone was what shipped in 2.80.14, read from a field that does
56
+ // not exist on a source file. It came back 0 on every session, so every
57
+ // variant was declared at the 400 kbit/s floor — nine times WORSE than the
58
+ // rule of thumb it replaced — and the browser, which sizes its cushion in
59
+ // bytes from this figure, held 0.1 s of film against 120 s asked. The picture
60
+ // stood still for 116.7 s of one viewing. Assuming a field instead of
61
+ // checking it is the whole of that fault.
62
+ const fromLength = length > 0 && duration > 0 ? (length * 8) / duration : 0;
63
+ const averageBitsPerSecond = Math.max(fromLength, Number(streamBitsPerSecond) || 0);
64
+ if (!(averageBitsPerSecond > 0)) {
65
+ return { averageBitsPerSecond: 0, peakOverAverage: 1 };
66
+ }
67
+ const index = Number(largest?.index);
68
+ const size = Number(largest?.size);
69
+ if (!Number.isInteger(index) || index < 0 || !(size > 0) || !Array.isArray(boundaries)) {
70
+ // Nothing has been produced, so the peak is not yet a measured quantity and
71
+ // is declared equal to the average. It rises as soon as one piece exists,
72
+ // and a ratio invented meanwhile would be exactly the fabrication this file
73
+ // replaced.
74
+ return { averageBitsPerSecond, peakOverAverage: 1 };
75
+ }
76
+ const span = Number(boundaries[index + 1]) - Number(boundaries[index]);
77
+ if (!(span > 0)) {
78
+ return { averageBitsPerSecond, peakOverAverage: 1 };
79
+ }
80
+ // Never below one: the peak cannot be under the average, and a piece that
81
+ // happens to be the smallest in a short session must not lower the figure the
82
+ // player sizes its cushion from.
83
+ return {
84
+ averageBitsPerSecond,
85
+ peakOverAverage: Math.max(1, ((size * 8) / span) / averageBitsPerSecond)
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The three arguments the master playlist needs to declare its rates.
91
+ *
92
+ * Assembled here rather than at the call site, because all three are facts
93
+ * about the OUTPUT and none of them is a fact about a session: the file's own
94
+ * length and duration, the biggest piece made of it, and the cap imposed on the
95
+ * one height being produced. The caller holds them and passes them as numbers.
96
+ *
97
+ * Only the height being produced has a cap — the other heights do not exist
98
+ * yet, and stating one for them would be a guess about a session nobody has
99
+ * made.
100
+ *
101
+ * @param {object} params
102
+ * @param {number} params.fileLength
103
+ * @param {number} params.durationSeconds
104
+ * @param {number} [params.streamBitsPerSecond]
105
+ * @param {{ index: number, size: number } | null} [params.largest]
106
+ * @param {number[] | null} [params.boundaries]
107
+ * @param {number} [params.producedHeight] - The height this output encodes at.
108
+ * @param {number} [params.capKbps] - What that height is capped at, if it is.
109
+ * @returns {{ averageBitsPerSecond: number, peakOverAverage: number,
110
+ * capKbpsFor: (height: number) => number }}
111
+ */
112
+ export function masterRateArgs({
113
+ fileLength,
114
+ durationSeconds,
115
+ streamBitsPerSecond = 0,
116
+ largest = null,
117
+ boundaries = null,
118
+ producedHeight = 0,
119
+ capKbps = 0
120
+ }) {
121
+ return {
122
+ ...declaredRates({ fileLength, durationSeconds, streamBitsPerSecond, largest, boundaries }),
123
+ capKbpsFor: (height) => (height === producedHeight ? capKbps : 0)
124
+ };
125
+ }
@@ -268,9 +268,16 @@ export class PieceLru {
268
268
  * a union wider than the capacity cannot be held however the eviction is
269
269
  * ordered.
270
270
  *
271
- * @returns {{ readers: number, unionPieces: number, widestPieces: number, capacity: number }}
271
+ * @returns {{ readers: number, names: string[], unionPieces: number, widestPieces: number, capacity: number }}
272
272
  */
273
273
  demand() {
274
+ // WHO THEY ARE, not only how many. A "reader" here is whoever declared a
275
+ // range, and on 2026-09-08 the field said `5 reader(s) want 24 piece(s) of
276
+ // 25` on a session with two encoders — because the priority map declares one
277
+ // range per zone and four of its zones were arriving as four readers. The
278
+ // count alone could not say that, and choosing between "narrow the windows"
279
+ // and "raise the allowance" was guesswork until the names were printed.
280
+ const names = [...this.#protected.keys()].map(String).sort();
274
281
  const ranges = [...this.#protected.values()]
275
282
  .map((range) => ({ from: range.from, to: range.to }))
276
283
  .sort((left, right) => left.from - right.from);
@@ -288,6 +295,7 @@ export class PieceLru {
288
295
  }
289
296
  return {
290
297
  readers: ranges.length,
298
+ names,
291
299
  unionPieces,
292
300
  widestPieces,
293
301
  capacity: this.#capacity