@torrent-tv/proxy 2.12.0 → 2.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## 2.12.2
|
|
2
|
+
|
|
3
|
+
- **Fix**: The transport heartbeat is written once per connection, with each channel's queue beside it. The counters it reports belong to the peer connection, not to a channel, so printing the line per channel produced two byte-for-byte identical readings — `sent=5153491` under both "proxy" and "proxy-control" on 2026-08-14 — which read as two independent measurements agreeing. The one figure that IS per channel, its queue depth, was the only real difference and was buried in a line that looked like a duplicate, leaving the second channel unobservable in the log.
|
|
4
|
+
- **Fix**: A channel watch ends when the transport stops knowing about its session, not only when the channel reports itself closed. The close callback is the ordinary way it ends and it does not always arrive — a peer connection can die without one — leaving a timer sampling a session that no longer exists for the life of the process.
|
|
5
|
+
- **Chore**: `host-timings.json` is no longer under version control, and no longer ships in the package. It is runtime state the proxy rewrites every session, so it arrived in every diff, would have carried one developer machine's medians into every published version, and would have conflicted on every release.
|
|
6
|
+
|
|
7
|
+
## 2.12.1
|
|
8
|
+
|
|
9
|
+
- **Fix**: The grid a copied stream is cut on now describes the FILE, not the container's index. A copy can only be cut where a keyframe already is, and nothing cheaper than the index can say where that is before a byte is encoded — but an index can be wrong. Reproduced 2026-08-12 against one file, both ways: with an honest index every produced segment started exactly where declared; with the index moved 1.8 s, every segment started 1.8 s early and matched no boundary at all. The field showed the second shape, so the mechanism was never at fault and the data was. The truth arrives anyway, one segment at a time — a produced piece states where it really begins — and it is now written back into the grid, which the whole family shares. That is what lets a re-encoded rung be cut to match a copied one: it is forced onto times the copy really uses. A correction that would cross its neighbours is refused, since that is a reading from a run that began somewhere else.
|
|
10
|
+
- **Fix**: A warm-up is no longer cancelled by the stream that is still playing. The cancellation stood before the check for whether the active rung had actually changed, and the rung on screen asks for its own segments every few seconds — so the rung being prepared was stopped 117 ms and 1.5 s after two warm-ups began (measured 2026-08-12), and the viewer then waited out the full thirty-second warm-up for a segment nobody was making, and waited again for the switch. One switch took 43.6 s.
|
|
11
|
+
- **Fix**: Warming the height the base session itself serves repositions it. It was skipped because it "is the base", but the base is parked wherever the viewer left it with its encoder stopped: warming 400p found it still at `run from #0`, so the switch had nothing to fetch.
|
|
12
|
+
- **Fix**: Repositioning inside this class names the session it means. `requestSeek` forwards to the rung on screen, which is right for the browser — it knows only the base id — and wrong for everything internal: warming a rung moved the rung already playing instead. Split into the public forwarding call and an internal literal one.
|
|
13
|
+
|
|
1
14
|
## 2.12.0
|
|
2
15
|
|
|
3
16
|
- **Fix**: A rung warmed for a switch the viewer did not make is stopped. Only becoming active stopped the rung being left, so trying two rungs in a row left the first encoding for nobody — three encoders at once on a host sized for one, which is the opposite of what warming is for.
|
package/package.json
CHANGED
|
@@ -103,10 +103,73 @@
|
|
|
103
103
|
* @returns {() => void} Stops the watch.
|
|
104
104
|
*/
|
|
105
105
|
function makeSendQueueWatcher({ log, getTransportSnapshot }) {
|
|
106
|
+
// Every channel of one connection reads the SAME transport counters — the
|
|
107
|
+
// snapshot describes the peer connection, not the channel — so the heartbeat
|
|
108
|
+
// belongs to the connection and is printed once for it. Printed per channel
|
|
109
|
+
// it produced two byte-for-byte identical lines (measured 2026-08-14:
|
|
110
|
+
// `sent=5153491` under both "proxy" and "proxy-control"), which read as two
|
|
111
|
+
// independent readings agreeing and made the second channel invisible: the
|
|
112
|
+
// one thing that IS per channel, its queue depth, was the only real
|
|
113
|
+
// difference and it was buried in a line that looked like a duplicate.
|
|
114
|
+
//
|
|
115
|
+
// sessionId → the channels currently open on that connection, and when it was
|
|
116
|
+
// last reported. Channels are keyed by the channel OBJECT, not by its label:
|
|
117
|
+
// a label is whatever the peer chose and two channels can carry the same one
|
|
118
|
+
// (or none, where `getLabel` is missing and both fall back to "?"), and a
|
|
119
|
+
// Map keyed on that would let one channel evict the other and then, on
|
|
120
|
+
// closing, delete the survivor's entry.
|
|
121
|
+
/** @type {Map<string, { channels: Map<DataChannel, string>, at: number, previous: object | null, unknown: number }>} */
|
|
122
|
+
const connections = new Map();
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What each channel of a connection is holding, right now.
|
|
126
|
+
*
|
|
127
|
+
* @param {Map<DataChannel, string>} channels
|
|
128
|
+
* @returns {string} `label:NB` per channel, in the order they opened.
|
|
129
|
+
*/
|
|
130
|
+
const queueDepths = (channels) => {
|
|
131
|
+
const parts = [];
|
|
132
|
+
for (const [openChannel, channelLabel] of channels) {
|
|
133
|
+
let depth = -1;
|
|
134
|
+
try {
|
|
135
|
+
depth = typeof openChannel.bufferedAmount === "function" ? openChannel.bufferedAmount() : 0;
|
|
136
|
+
} catch {
|
|
137
|
+
depth = -1;
|
|
138
|
+
}
|
|
139
|
+
parts.push(`${channelLabel}:${depth}B`);
|
|
140
|
+
}
|
|
141
|
+
return parts.join(" ");
|
|
142
|
+
};
|
|
143
|
+
|
|
106
144
|
return function watchSendQueue(sessionId, tag, label, channel) {
|
|
107
145
|
let lowestSinceDrain = Number.POSITIVE_INFINITY;
|
|
108
146
|
let stuckSince = 0;
|
|
109
147
|
let previous = null;
|
|
148
|
+
let connection = connections.get(sessionId);
|
|
149
|
+
if (!connection) {
|
|
150
|
+
connection = { channels: new Map(), at: 0, previous: null, unknown: 0 };
|
|
151
|
+
connections.set(sessionId, connection);
|
|
152
|
+
}
|
|
153
|
+
connection.channels.set(channel, label);
|
|
154
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
155
|
+
let timer = null;
|
|
156
|
+
/**
|
|
157
|
+
* End this channel's watch and let go of its entry.
|
|
158
|
+
*
|
|
159
|
+
* @returns {void}
|
|
160
|
+
*/
|
|
161
|
+
const stop = () => {
|
|
162
|
+
if (timer) {
|
|
163
|
+
clearInterval(timer);
|
|
164
|
+
}
|
|
165
|
+
connection.channels.delete(channel);
|
|
166
|
+
// Only if the map still holds THIS record: a late stop, after the same
|
|
167
|
+
// session id has been reused and a new record made for it, must not evict
|
|
168
|
+
// the live one.
|
|
169
|
+
if (connection.channels.size === 0 && connections.get(sessionId) === connection) {
|
|
170
|
+
connections.delete(sessionId);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
110
173
|
// Independent of the queue: the transport's own counters, sampled for as
|
|
111
174
|
// long as the channel is open. The queue was the wrong thing to watch —
|
|
112
175
|
// field 2026-08-06, a 9.26 MB segment was accepted by the transport with
|
|
@@ -115,30 +178,38 @@ function makeSendQueueWatcher({ log, getTransportSnapshot }) {
|
|
|
115
178
|
// same way while requests kept coming the other direction. With nothing
|
|
116
179
|
// queued this watcher never woke, so the one question that matters — did
|
|
117
180
|
// those bytes leave the machine — has no answer in the log. It does now.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
181
|
+
// A connection the transport no longer knows about is gone, whatever the
|
|
182
|
+
// channel says. `onClosed` is the ordinary way this watch ends, and it does
|
|
183
|
+
// not always come — a peer connection can die without it, leaving the timer
|
|
184
|
+
// and this channel's entry behind for the life of the process.
|
|
185
|
+
//
|
|
186
|
+
// The count is kept on the CONNECTION: exactly one channel enters the
|
|
187
|
+
// heartbeat branch per interval, so a per-channel count would advance only
|
|
188
|
+
// on that channel's turn and the teardown would take three heartbeats per
|
|
189
|
+
// channel rather than three in total.
|
|
190
|
+
timer = setInterval(() => {
|
|
122
191
|
const sampledAt = Date.now();
|
|
123
|
-
|
|
124
|
-
|
|
192
|
+
// Whichever channel's timer arrives first past the interval reports for
|
|
193
|
+
// the whole connection; the others find the timestamp already moved and
|
|
194
|
+
// skip. So the line appears once however many channels are open.
|
|
195
|
+
if (sampledAt - connection.at >= TRANSPORT_HEARTBEAT_MS) {
|
|
196
|
+
connection.at = sampledAt;
|
|
125
197
|
const snapshot = getTransportSnapshot?.(sessionId) ?? null;
|
|
198
|
+
connection.unknown = snapshot ? 0 : connection.unknown + 1;
|
|
199
|
+
if (connection.unknown >= TRANSPORT_UNKNOWN_HEARTBEATS) {
|
|
200
|
+
stop();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
126
203
|
if (snapshot) {
|
|
127
|
-
const sent =
|
|
128
|
-
const received =
|
|
129
|
-
? snapshot.bytesReceived -
|
|
204
|
+
const sent = connection.previous ? snapshot.bytesSent - connection.previous.bytesSent : null;
|
|
205
|
+
const received = connection.previous
|
|
206
|
+
? snapshot.bytesReceived - connection.previous.bytesReceived
|
|
130
207
|
: null;
|
|
131
|
-
|
|
132
|
-
let depth = 0;
|
|
133
|
-
try {
|
|
134
|
-
depth = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
|
|
135
|
-
} catch {
|
|
136
|
-
depth = -1;
|
|
137
|
-
}
|
|
208
|
+
connection.previous = snapshot;
|
|
138
209
|
log(
|
|
139
|
-
`[dc-transport] ${tag}
|
|
210
|
+
`[dc-transport] ${tag} sent=${snapshot.bytesSent}` +
|
|
140
211
|
`${sent === null ? "" : ` (+${sent})`} received=${snapshot.bytesReceived}` +
|
|
141
|
-
`${received === null ? "" : ` (+${received})`} queued
|
|
212
|
+
`${received === null ? "" : ` (+${received})`} queued[${queueDepths(connection.channels)}] ` +
|
|
142
213
|
`rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
|
|
143
214
|
);
|
|
144
215
|
}
|
|
@@ -184,7 +255,7 @@ function makeSendQueueWatcher({ log, getTransportSnapshot }) {
|
|
|
184
255
|
if (typeof timer.unref === "function") {
|
|
185
256
|
timer.unref();
|
|
186
257
|
}
|
|
187
|
-
return
|
|
258
|
+
return stop;
|
|
188
259
|
};
|
|
189
260
|
}
|
|
190
261
|
|
|
@@ -668,6 +739,11 @@ const SEND_QUEUE_SAMPLE_MS = 1_000;
|
|
|
668
739
|
// send queue is doing. Frequent enough to place a loss within a few seconds,
|
|
669
740
|
// sparse enough that a two-hour film costs a few hundred lines.
|
|
670
741
|
const TRANSPORT_HEARTBEAT_MS = 5_000;
|
|
742
|
+
|
|
743
|
+
// How many heartbeats in a row may find no transport for this session before
|
|
744
|
+
// the watch gives up. Several rather than one, so a momentary gap in the
|
|
745
|
+
// registry does not end a healthy watch.
|
|
746
|
+
const TRANSPORT_UNKNOWN_HEARTBEATS = 3;
|
|
671
747
|
const SEND_QUEUE_STUCK_MS = 5_000;
|
|
672
748
|
const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
|
|
673
749
|
/** Resume sending once the channel buffer drains to this many bytes. */
|
|
@@ -1419,15 +1419,23 @@ export class HlsSessionManager {
|
|
|
1419
1419
|
Array.isArray(keyframeTimes) &&
|
|
1420
1420
|
keyframeTimes.length > 0 &&
|
|
1421
1421
|
(!transcodeVideo || inheritedGrid != null);
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
:
|
|
1422
|
+
// A rung takes the grid it was handed, rather than working one out again
|
|
1423
|
+
// from the same index. The two are not the same table: the one it is handed
|
|
1424
|
+
// has been CORRECTED wherever a produced segment showed the index to be
|
|
1425
|
+
// wrong, and it is those corrected times the copy actually cuts at. Building
|
|
1426
|
+
// it afresh here would put the rung back on the index's fiction and undo the
|
|
1427
|
+
// alignment it exists for.
|
|
1428
|
+
const segmentBoundaries = Array.isArray(inheritedGrid?.boundaries) && inheritedGrid.boundaries.length > 1
|
|
1429
|
+
? [...inheritedGrid.boundaries]
|
|
1430
|
+
: (hasDuration
|
|
1431
|
+
? computeSegmentBoundaries({
|
|
1432
|
+
useKeyframeGrid,
|
|
1433
|
+
durationSeconds,
|
|
1434
|
+
segDur: this.segmentDurationSec,
|
|
1435
|
+
keyframeTimes,
|
|
1436
|
+
startTime: sourceStartTime
|
|
1437
|
+
})
|
|
1438
|
+
: []);
|
|
1431
1439
|
const usingKeyframeBoundaries = useKeyframeGrid;
|
|
1432
1440
|
const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
|
|
1433
1441
|
|
|
@@ -3354,7 +3362,27 @@ export class HlsSessionManager {
|
|
|
3354
3362
|
// variants, so a seek it reports means the stream on screen.
|
|
3355
3363
|
named.viewerPositionSeconds = positionSeconds;
|
|
3356
3364
|
named.lastAccessedAt = Date.now();
|
|
3357
|
-
|
|
3365
|
+
return this.#seekSession(this.#activeVariant(named), positionSeconds);
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
/**
|
|
3369
|
+
* Reposition THIS session, with no forwarding.
|
|
3370
|
+
*
|
|
3371
|
+
* {@link requestSeek} exists for the browser, which names the base session and
|
|
3372
|
+
* means the rung on screen. Everything inside this class means the session it
|
|
3373
|
+
* is holding: warming a rung has to move THAT rung, and forwarding sent the
|
|
3374
|
+
* seek to the one already playing instead — measured 2026-08-12, warming the
|
|
3375
|
+
* base's own height moved the 540p rung and left the base parked at the start,
|
|
3376
|
+
* so the switch had nothing to fetch.
|
|
3377
|
+
*
|
|
3378
|
+
* @param {HlsSession} session
|
|
3379
|
+
* @param {number} positionSeconds
|
|
3380
|
+
* @returns {boolean}
|
|
3381
|
+
*/
|
|
3382
|
+
#seekSession(session, positionSeconds) {
|
|
3383
|
+
if (!session || session.state === "disposed") {
|
|
3384
|
+
return false;
|
|
3385
|
+
}
|
|
3358
3386
|
session.viewerPositionSeconds = positionSeconds;
|
|
3359
3387
|
session.lastAccessedAt = Date.now();
|
|
3360
3388
|
// Every segment request being held right now was made for the position the
|
|
@@ -3794,6 +3822,92 @@ export class HlsSessionManager {
|
|
|
3794
3822
|
: "the container's keyframe index disagrees with the file; using the file")
|
|
3795
3823
|
);
|
|
3796
3824
|
}
|
|
3825
|
+
this.correctBoundaryFromSegment(session, index, trueStart);
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
/**
|
|
3829
|
+
* Replace a boundary the index got wrong with the time the file actually has.
|
|
3830
|
+
*
|
|
3831
|
+
* The grid of a copied stream comes from the container's keyframe index,
|
|
3832
|
+
* because a copy can only be cut where a keyframe already is and nothing
|
|
3833
|
+
* cheaper than the index can say where that is before a single byte is
|
|
3834
|
+
* encoded. An index can be wrong — proven 2026-08-12 by reproducing both
|
|
3835
|
+
* cases against the same file: with an honest index every produced segment
|
|
3836
|
+
* started exactly where declared, and with one moved 1.8 s the segments
|
|
3837
|
+
* started 1.8 s early, matching no boundary at all. The field showed the
|
|
3838
|
+
* second shape.
|
|
3839
|
+
*
|
|
3840
|
+
* The truth arrives anyway, one segment at a time: a produced piece states
|
|
3841
|
+
* where it really begins. Writing it back makes the grid describe the file
|
|
3842
|
+
* instead of the index — and it is what lets a re-encoded rung be cut to
|
|
3843
|
+
* match a copied one, because the rung is then forced onto times the copy
|
|
3844
|
+
* really uses. The alternative considered and rejected was to stop offering
|
|
3845
|
+
* quality on files with a bad index, which is not a fix but a withdrawal.
|
|
3846
|
+
*
|
|
3847
|
+
* The whole family shares one grid, so a correction reaches all of it: a rung
|
|
3848
|
+
* created afterwards inherits a table that is true wherever anyone has looked.
|
|
3849
|
+
*
|
|
3850
|
+
* @param {HlsSession} session
|
|
3851
|
+
* @param {number} index
|
|
3852
|
+
* @param {number} trueStart
|
|
3853
|
+
* @returns {void}
|
|
3854
|
+
*/
|
|
3855
|
+
correctBoundaryFromSegment(session, index, trueStart) {
|
|
3856
|
+
const boundaries = session.segmentBoundaries;
|
|
3857
|
+
if (!Array.isArray(boundaries) || index <= 0 || index >= boundaries.length - 1) {
|
|
3858
|
+
// Index 0 is the start of the file and the last entry is its end; neither
|
|
3859
|
+
// is a cut, and neither can be learned from a segment.
|
|
3860
|
+
return;
|
|
3861
|
+
}
|
|
3862
|
+
if (Math.abs(boundaries[index] - trueStart) <= SEGMENT_START_DISAGREEMENT_SEC) {
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
// A correction that would put this boundary at or past its neighbours is not
|
|
3866
|
+
// a correction — it is a reading from a run that started somewhere else, and
|
|
3867
|
+
// applying it would make the table describe nothing at all.
|
|
3868
|
+
if (trueStart <= boundaries[index - 1] || trueStart >= boundaries[index + 1]) {
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3871
|
+
const wasAt = boundaries[index];
|
|
3872
|
+
for (const member of this.#familyOf(session)) {
|
|
3873
|
+
if (Array.isArray(member.segmentBoundaries) && member.segmentBoundaries.length === boundaries.length) {
|
|
3874
|
+
member.segmentBoundaries[index] = trueStart;
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
logger.info(
|
|
3878
|
+
`transcode ${session.id} boundary #${index} corrected ${wasAt.toFixed(3)}s → ` +
|
|
3879
|
+
`${trueStart.toFixed(3)}s from the file itself`
|
|
3880
|
+
);
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
/**
|
|
3884
|
+
* Every session cut on one grid: a base and its quality rungs.
|
|
3885
|
+
*
|
|
3886
|
+
* @param {HlsSession} session
|
|
3887
|
+
* @returns {HlsSession[]}
|
|
3888
|
+
*/
|
|
3889
|
+
#familyOf(session) {
|
|
3890
|
+
const bases = session.variantBases instanceof Set
|
|
3891
|
+
? [...session.variantBases]
|
|
3892
|
+
: [];
|
|
3893
|
+
const roots = bases.length > 0 ? bases : [session.id];
|
|
3894
|
+
const family = new Set([session]);
|
|
3895
|
+
for (const rootId of roots) {
|
|
3896
|
+
const root = this.sessionsById.get(rootId);
|
|
3897
|
+
if (!root) {
|
|
3898
|
+
continue;
|
|
3899
|
+
}
|
|
3900
|
+
family.add(root);
|
|
3901
|
+
if (root.variants instanceof Map) {
|
|
3902
|
+
for (const variantId of root.variants.values()) {
|
|
3903
|
+
const variant = this.sessionsById.get(variantId);
|
|
3904
|
+
if (variant) {
|
|
3905
|
+
family.add(variant);
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
return [...family];
|
|
3797
3911
|
}
|
|
3798
3912
|
|
|
3799
3913
|
/**
|
|
@@ -4103,7 +4217,13 @@ export class HlsSessionManager {
|
|
|
4103
4217
|
// be interchangeable with it. A base on the uniform grid needs nothing
|
|
4104
4218
|
// passed: the variant computes the same even grid from the same duration.
|
|
4105
4219
|
inheritedGrid: base.cutGrid === "keyframe"
|
|
4106
|
-
? {
|
|
4220
|
+
? {
|
|
4221
|
+
// The table as it stands NOW, corrections included — not the index
|
|
4222
|
+
// it was first built from.
|
|
4223
|
+
boundaries: base.segmentBoundaries,
|
|
4224
|
+
keyframeTimes: base.keyframeTimes,
|
|
4225
|
+
containerFormat: base.containerFormat
|
|
4226
|
+
}
|
|
4107
4227
|
: null,
|
|
4108
4228
|
acquireSource: base.acquireSource
|
|
4109
4229
|
})
|
|
@@ -4257,8 +4377,14 @@ export class HlsSessionManager {
|
|
|
4257
4377
|
// the switch position exactly as an activation would — the difference is
|
|
4258
4378
|
// only that the rung on screen keeps its own encoder meanwhile.
|
|
4259
4379
|
variant.lastAccessedAt = Date.now();
|
|
4260
|
-
|
|
4261
|
-
|
|
4380
|
+
// Anything that is not the rung on screen has to be pointed at the switch
|
|
4381
|
+
// position — INCLUDING the base. Skipping it because it is the base was a
|
|
4382
|
+
// defect: the base is parked wherever it was when the viewer left it, and
|
|
4383
|
+
// its encoder was stopped then. Measured 2026-08-12, warming 400p at
|
|
4384
|
+
// 6506.5s found the base still at `run from #0`, so the segment the switch
|
|
4385
|
+
// needed was never produced and the viewer got nothing at all.
|
|
4386
|
+
if (variant.id !== this.#activeVariant(base).id) {
|
|
4387
|
+
this.#seekSession(variant, this.#segmentStartTime(base, index));
|
|
4262
4388
|
}
|
|
4263
4389
|
logger.info(
|
|
4264
4390
|
`transcode ${base.id} warming ${height}p at ${positionSeconds.toFixed(1)}s (segment #${index})`
|
|
@@ -4281,10 +4407,20 @@ export class HlsSessionManager {
|
|
|
4281
4407
|
*/
|
|
4282
4408
|
#noteVariantActive(base, variant, wantedIndex = -1) {
|
|
4283
4409
|
const previous = this.#activeVariant(base);
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4410
|
+
if (previous.id === variant.id) {
|
|
4411
|
+
// The rung on screen asking for more of itself, which it does every few
|
|
4412
|
+
// seconds. Nothing is being decided here — and deciding anything was the
|
|
4413
|
+
// defect: the warm-up was cancelled by the next segment the CURRENT rung
|
|
4414
|
+
// fetched, measured 2026-08-12 at 117 ms and 1.5 s after two warm-ups
|
|
4415
|
+
// began, so the rung being prepared was stopped before it had encoded
|
|
4416
|
+
// anything and the viewer waited out the full thirty-second warm-up for a
|
|
4417
|
+
// segment nobody was making, then waited again for the switch itself.
|
|
4418
|
+
return;
|
|
4419
|
+
}
|
|
4420
|
+
// A rung is being left, so whatever was warmed is decided: either it is the
|
|
4421
|
+
// rung now being switched to, or the viewer went somewhere else and it must
|
|
4422
|
+
// stop like any other rung nobody is watching. Nothing else would ever stop
|
|
4423
|
+
// it — only the rung being LEFT is stopped below.
|
|
4288
4424
|
const warmed = base.warmingVariantId;
|
|
4289
4425
|
base.warmingVariantId = null;
|
|
4290
4426
|
if (warmed && warmed !== variant.id && warmed !== previous.id) {
|
|
@@ -4293,9 +4429,6 @@ export class HlsSessionManager {
|
|
|
4293
4429
|
this.#stopEncodeRun(abandoned, "warmed for a switch the viewer did not make");
|
|
4294
4430
|
}
|
|
4295
4431
|
}
|
|
4296
|
-
if (previous.id === variant.id) {
|
|
4297
|
-
return;
|
|
4298
|
-
}
|
|
4299
4432
|
const position = this.#variantStartSeconds(base, wantedIndex);
|
|
4300
4433
|
base.activeVariantId = variant.id;
|
|
4301
4434
|
logger.info(
|
|
@@ -4310,10 +4443,12 @@ export class HlsSessionManager {
|
|
|
4310
4443
|
this.#stopEncodeRun(previous, `the viewer moved to ${this.variantHeightOf(variant)}p`);
|
|
4311
4444
|
if (position > 0) {
|
|
4312
4445
|
variant.viewerPositionSeconds = position;
|
|
4313
|
-
//
|
|
4314
|
-
//
|
|
4315
|
-
//
|
|
4316
|
-
|
|
4446
|
+
// The rung being switched TO, named literally: a warm-up may have left
|
|
4447
|
+
// the family pointing elsewhere, and forwarding would move that one
|
|
4448
|
+
// instead. A rung just created already starts here and is told so rather
|
|
4449
|
+
// than restarted; one that existed before is parked where it was left,
|
|
4450
|
+
// and this is what brings it to the viewer.
|
|
4451
|
+
this.#seekSession(variant, position);
|
|
4317
4452
|
}
|
|
4318
4453
|
}
|
|
4319
4454
|
|
|
@@ -56,6 +56,67 @@ test("a deviation within tolerance is not a disagreement, but still shows in the
|
|
|
56
56
|
assert.equal(check.maxDeviationSec, 0.2, "and it is still worth knowing how close to the line it ran");
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test("a boundary the index got wrong is replaced by the time the file really has", async (t) => {
|
|
60
|
+
const { HlsSessionManager } = await import("../services/hls-session-manager.js");
|
|
61
|
+
const manager = new HlsSessionManager({
|
|
62
|
+
enabled: true,
|
|
63
|
+
ffmpegBin: "ffmpeg",
|
|
64
|
+
localBindHost: "127.0.0.1",
|
|
65
|
+
localPort: 9090
|
|
66
|
+
});
|
|
67
|
+
t.after(() => manager.disposeAll());
|
|
68
|
+
const base = {
|
|
69
|
+
id: "aaaaaaaa-1111-2222-3333-444444444444",
|
|
70
|
+
fileName: "film.mkv",
|
|
71
|
+
state: "ready",
|
|
72
|
+
transcodeVideo: false,
|
|
73
|
+
segmentBoundaries: [0, 10, 20, 30, 40],
|
|
74
|
+
indexCheck: newIndexCheck(),
|
|
75
|
+
variants: new Map(),
|
|
76
|
+
segmentFormat: { segmentFileName: (index) => `segment-${index}.mp4` }
|
|
77
|
+
};
|
|
78
|
+
const rung = {
|
|
79
|
+
id: "bbbbbbbb-1111-2222-3333-444444444444",
|
|
80
|
+
fileName: "film.mkv",
|
|
81
|
+
state: "ready",
|
|
82
|
+
transcodeVideo: true,
|
|
83
|
+
segmentBoundaries: [0, 10, 20, 30, 40],
|
|
84
|
+
indexCheck: newIndexCheck(),
|
|
85
|
+
variantBases: new Set([base.id])
|
|
86
|
+
};
|
|
87
|
+
base.variants.set(540, rung.id);
|
|
88
|
+
manager.sessionsById.set(base.id, base);
|
|
89
|
+
manager.sessionsById.set(rung.id, rung);
|
|
90
|
+
|
|
91
|
+
// The copy produced segment #2, and it really begins at 17.4 s — the index
|
|
92
|
+
// said 20. This is the shape reproduced from the field on 2026-08-12.
|
|
93
|
+
manager.correctBoundaryFromSegment(base, 2, 17.4);
|
|
94
|
+
|
|
95
|
+
assert.equal(
|
|
96
|
+
base.segmentBoundaries[2],
|
|
97
|
+
17.4,
|
|
98
|
+
"the grid must describe the file, not the index — a rung forced onto 20 s would not join the copy"
|
|
99
|
+
);
|
|
100
|
+
assert.equal(
|
|
101
|
+
rung.segmentBoundaries[2],
|
|
102
|
+
17.4,
|
|
103
|
+
"the family shares one grid, so a correction reaches the rungs cut against it"
|
|
104
|
+
);
|
|
105
|
+
assert.deepEqual(
|
|
106
|
+
base.segmentBoundaries,
|
|
107
|
+
[0, 10, 17.4, 30, 40],
|
|
108
|
+
"only the boundary that was shown to be wrong moves"
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// A reading that cannot be a boundary is not evidence about one. It comes
|
|
112
|
+
// from a run that started somewhere else, and applying it would leave the
|
|
113
|
+
// table describing nothing.
|
|
114
|
+
manager.correctBoundaryFromSegment(base, 2, 35);
|
|
115
|
+
manager.correctBoundaryFromSegment(base, 2, 5);
|
|
116
|
+
manager.correctBoundaryFromSegment(base, 0, 3);
|
|
117
|
+
assert.deepEqual(base.segmentBoundaries, [0, 10, 17.4, 30, 40], "out-of-order readings are refused");
|
|
118
|
+
});
|
|
119
|
+
|
|
59
120
|
test("a segment requested again is not new evidence", () => {
|
|
60
121
|
const check = newIndexCheck();
|
|
61
122
|
|
|
@@ -310,6 +310,62 @@ test("warming a rung prepares it without taking the encoder from the one on scre
|
|
|
310
310
|
assert.deepEqual(encoder.signals, [], "stopping it here is what would put the spinner back");
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
+
test("the rung on screen fetching its own segments does not cancel a warm-up", async (t) => {
|
|
314
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
315
|
+
t.after(async () => {
|
|
316
|
+
await manager.disposeAll();
|
|
317
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
318
|
+
});
|
|
319
|
+
const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
|
|
320
|
+
variant.variantHeight = 540;
|
|
321
|
+
variant.variantBases = new Set([BASE_ID]);
|
|
322
|
+
manager.sessionsById.set(VARIANT_ID, variant);
|
|
323
|
+
base.variants = new Map([[540, VARIANT_ID]]);
|
|
324
|
+
const warmedEncoder = fakeEncoder();
|
|
325
|
+
variant.ffmpeg = warmedEncoder;
|
|
326
|
+
base.ffmpeg = fakeEncoder();
|
|
327
|
+
await manager.prepareVariant(BASE_ID, 540, 100);
|
|
328
|
+
|
|
329
|
+
// The viewer has not moved: the rung they are watching goes on asking for its
|
|
330
|
+
// own segments, every few seconds, for as long as they watch.
|
|
331
|
+
await manager.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4");
|
|
332
|
+
await manager.resolveVariantFile(BASE_ID, 812, "segment-00027.mp4");
|
|
333
|
+
|
|
334
|
+
assert.equal(base.warmingVariantId, VARIANT_ID, "the rung being prepared is still being prepared");
|
|
335
|
+
assert.deepEqual(
|
|
336
|
+
warmedEncoder.signals,
|
|
337
|
+
[],
|
|
338
|
+
"cancelling it here left the viewer waiting out the whole warm-up for a segment nobody was making"
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("warming the height the base itself serves still points it at the switch", async (t) => {
|
|
343
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
344
|
+
t.after(async () => {
|
|
345
|
+
await manager.disposeAll();
|
|
346
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
347
|
+
});
|
|
348
|
+
// The viewer is on another rung; the base is parked where they left it, with
|
|
349
|
+
// its encoder stopped. Warming its height must bring it back.
|
|
350
|
+
const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
|
|
351
|
+
variant.variantHeight = 540;
|
|
352
|
+
manager.sessionsById.set(VARIANT_ID, variant);
|
|
353
|
+
base.variants = new Map([[540, VARIANT_ID]]);
|
|
354
|
+
base.activeVariantId = VARIANT_ID;
|
|
355
|
+
base.ffmpeg = null;
|
|
356
|
+
base.encodeStartIndex = 0;
|
|
357
|
+
|
|
358
|
+
await manager.prepareVariant(BASE_ID, 812, 400);
|
|
359
|
+
|
|
360
|
+
// 400 s falls on the boundary between #99 and #100, and a run starts one
|
|
361
|
+
// segment back so the player has the preceding keyframe.
|
|
362
|
+
assert.equal(
|
|
363
|
+
base.seekTarget,
|
|
364
|
+
98,
|
|
365
|
+
"the base is parked at the start, so warming its height must reposition it like any other rung"
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
313
369
|
test("the viewer's position is kept current by the segments they ask for", async (t) => {
|
|
314
370
|
const { manager, base, dirPath } = await managerWithBase();
|
|
315
371
|
t.after(async () => {
|
package/host-timings.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"firstSegment":[3,1,4,11,3,1,3,17],"sessionCreate":[]}
|