@torrent-tv/proxy 2.49.0 → 2.51.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 +17 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -0
- package/server.js +4 -0
- package/services/container-index/avi.js +12 -1
- package/services/container-index/index.js +18 -6
- package/services/hls-session-manager.js +287 -19
- package/services/torrent-pool.js +165 -2
- package/services/torrent-worker/worker.js +9 -2
- package/test/dht-bootstrap.test.js +72 -0
- package/test/run-position-follows-published-grid.test.js +87 -0
- package/test/seek-landing.test.js +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## 2.51.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: A run is POSITIONED where the player was told the segment begins, on the same table its cuts are stated on. There are two boundary tables — the one the playlist text was written from, which never changes, and the live one, corrected as produced segments reveal where the file's cuts truly are. 2.45.0 moved the CUT LIST onto the published table and left the position on the live one, and that is one fault rather than two: `-segment_times` are measured from wherever the run really began, so any distance between the two carries into EVERY cut the run makes. The corrections run backwards, so each restart began a little earlier than the grid its cuts were stated on, and because the corrections accumulate, so did the distance. Measured 2026-08-21 on `JUFD665.mp4` — an MP4 whose index was read cleanly, 1765 keyframes, served by copy: after one seek restart a produced segment held the boundary **two** places before its own number (16.684 s, exactly 2.0000 segments), after the next restart **four** (33.5 s). The player's buffer then stops extending at all, because every fragment's content lands before the time its playlist entry names: `bufferEnd` stood still at 4571.1 s through four `frag-far` warnings until hls.js gave up and jumped the viewer 16.8 s forward. Four of those jumps in one window is what the viewer reported as sticking on every seek.
|
|
4
|
+
- **Fix**: The line about a segment that began away from its grid follows the numbers instead of the branch it is printed from. On the copy path it always read "the container's keyframe index disagrees with the file", including in the case this code's own neighbouring comment defines as a fault in this code — a segment holding another boundary **of the same list**. It now says which of the two it is, because they need opposite fixes and that sentence is what sent the reading of the session above after the file instead of after the arithmetic. The soundtrack's variant of the sentence stops claiming anything about a keyframe index: a soundtrack has no keyframes and is cut exactly where it was asked to be.
|
|
5
|
+
- **New**: A run says where it was positioned — the time, the boundary it came from, that boundary's time in the PUBLISHED table and in the LIVE one, the distance between them, and the number it starts counting from. Four numbers whose disagreement was invisible everywhere else.
|
|
6
|
+
- **New**: A correction to the live boundary table states the total drift it is part of. Corrections are applied one boundary at a time and each is small enough to look harmless; nobody was watching the sum, which is the quantity that actually moves the cuts.
|
|
7
|
+
- **Fix**: The realignment that starts the sound where a copied picture truly begins is given that instant EXPLICITLY. It used to be smuggled through the live boundary table — the correction wrote the measured time there and the restarted run read its position from the same place — which stops working the moment a run positions itself on the table the player holds, as it now must. Smuggled, the restart would land exactly where it already was: picture and sound stay apart AND a healthy soundtrack run is discarded for nothing, which is the shape the field already showed on 2026-08-21 — eleven audio restarts in under four minutes, eight of them dying with `run had produced 0.0s`.
|
|
8
|
+
- **Fix**: What a run was asked for is taken from the run rather than looked up again in a table. Two lines did the second — the one reporting where a run landed, and the one explaining a held segment — and once the position moved to the published grid they could disagree with the run by the distance between the two tables: a perfect landing reported as a drift, a real drift of the same size cancelled to zero, and a held segment printing a negative "produced" that sends the reader after the torrent when the encoder is the subject.
|
|
9
|
+
- **New**: The player's own "this fragment is nowhere near my buffer" reading is answered instead of being left in its console. `POST /api/transcode-sessions/:id/fragment-far` takes the report and the proxy replies in the log with the one fact only this side holds: which boundary the segment of that number really begins at, and whether that is the one its number claims. The report says which stream it is about, and a report about the soundtrack is answered from the soundtrack's own session: picture and sound are produced by two sessions positioned by two runs, which is how they come apart, so answering one from the other's records would state something confident about the wrong stream. Diagnostic only — nothing is repositioned on the strength of a browser's reading, because a wrong answer there would restart an encoder a viewer is waiting on.
|
|
10
|
+
- **Fix**: The DHT is given entry points that answer. Measured 2026-08-21 from the addon host: of the three bootstrap nodes the library ships, `router.bittorrent.com` and `router.utorrent.com` replied to nothing while a control datagram to a DNS server came back in 20 ms, and the third, `dht.transmissionbt.com`, is alive — it answered `find_node` with eight nodes — but on a host with global IPv6 its name resolves to an IPv6 address first while the DHT's socket is IPv4, so by name it was never reached. The list now carries a live node, and the names are resolved to IPv4 here rather than left to whichever family the host prefers. Measured on that host: 0 nodes after 21 s by name, 22 nodes in 5 s by address. The resolution is capped at two seconds per name, because it is awaited before the torrent client exists and a host whose resolver black-holes would otherwise hold that thread for c-ares' own four tries with nothing said. What it cost: on `JUFD665.mp4` the tracker answered `seeders=5` at 13:40:30 and the first peer connected at 13:44:47 — 4 min 17 s of a viewer watching an unexplained wait with an empty routing table beside them. A private torrent still ignores the DHT, by specification.
|
|
11
|
+
- **New**: The size of the DHT's routing table is said a minute after start, and an empty one is said as a warning. A bootstrap list rots — that is exactly what had happened here — and nothing reported it for as long as it was wrong, so the next list to die would be found the same way this one was: by hand, after a viewer waited four minutes.
|
|
12
|
+
|
|
13
|
+
## 2.50.0
|
|
14
|
+
|
|
15
|
+
- **Fix**: An AVI seek is asked for late enough to survive the container's own arithmetic. AVI names a keyframe by its frame NUMBER, and `services/container-index/avi.js` turned that into a time by multiplying by the frame duration the header declares — which lands 10-44 ms from the presentation time the demuxer computes, always under one frame (measured 2026-08-21 against the files themselves: 1196 index entries against 1196 real keyframes and 901 against 901, the frames exactly right and only their names off). A name sitting just BELOW its real keyframe seeks to before it and lands on the one before that, which is the same fault the landing offset already exists for. The reader now declares how far its times may be, and the request carries that on top. Matroska and MP4 declare nothing, because they state instants outright — nine files and 11 665 keyframes with not one disagreement.
|
|
16
|
+
- **Fix**: A container with no keyframe index is re-encoded rather than copied against a grid nobody knows. MPEG-TS carries no index of any kind — measured the same day, 669 real keyframes and nothing to read them from without walking the file — and a copied picture can only be cut at the source's own keyframes, so declaring an even grid is a falsehood the player punishes: it walks the whole file to rebuild the timeline, or presents audio with no picture because a segment begins with nothing decodable, both field-observed 2026-08-02. Re-encoding PLACES keyframes on our own cuts, so the grid is right by construction whatever the container. A container whose index could not be read inside the budget lands here too, for the same reason. It costs an encoder, and the alternative was a broken playlist.
|
|
17
|
+
|
|
1
18
|
## 2.49.0
|
|
2
19
|
|
|
3
20
|
- **Fix**: The torrent worker is allowed to END rather than being torn down under itself. A core dump read on 2026-08-21 named the fault the proxy has been dying of: `SIGSEGV` in `v8::Value::IsArrayBufferView` reached through `napi_get_buffer_info` from utp-native's `on_utp_accept`, called from its UDP read — all of it inside `node::Environment::CleanupHandles`, under `FreeEnvironment`, on `Worker::Run`. That is a teardown race, not a data fault, which is why neither patch our forked library already carries touched it: a datagram arriving while the environment is being freed walks into an isolate that no longer exists. `destroyAll` called `Worker.terminate()` immediately after destroying the client inside, and `terminate()` frees the environment with libuv's handle callbacks still queued. It now waits for the thread to exit by itself — once the client is destroyed nothing holds its loop open — with `terminate()` kept as a five-second fallback, because a shutdown that hangs is worse than one that is forced.
|
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accept the player's report that a delivered fragment sits far from the edge
|
|
3
|
+
* of what it has buffered.
|
|
4
|
+
*
|
|
5
|
+
* This is the earliest statement that exists of a stream coming apart, and
|
|
6
|
+
* until now it lived only in the browser's console. Measured 2026-08-21 on
|
|
7
|
+
* `JUFD665.mp4`: four of these across half a minute, each naming a gap of
|
|
8
|
+
* ~33.5 s, while the buffer stood still at 4571.1 s — and then hls.js gave up
|
|
9
|
+
* and jumped the viewer 16.8 s forward. The proxy is the only side that can say
|
|
10
|
+
* what the gap MEANS, because only it knows which boundary the segment it
|
|
11
|
+
* produced actually holds, and whether that is the one its number claims.
|
|
12
|
+
*
|
|
13
|
+
* POST /api/transcode-sessions/:sessionId/fragment-far
|
|
14
|
+
* Body: { sn: number, track?: string, fragStartSec: number, bufferEndSec: number, currentTimeSec: number }
|
|
15
|
+
*
|
|
16
|
+
* `track` is hls.js's own name for the stream the fragment belongs to ("main"
|
|
17
|
+
* or "audio"). It matters because picture and sound are produced by two
|
|
18
|
+
* different sessions positioned by two different runs — which is how they come
|
|
19
|
+
* apart in the first place — so a report answered against the wrong one is a
|
|
20
|
+
* confident statement about a stream nobody asked about.
|
|
21
|
+
*
|
|
22
|
+
* Diagnostic only — it changes nothing about the encode. Invalid body → 400,
|
|
23
|
+
* unknown session → 404, ok → 204.
|
|
24
|
+
*
|
|
25
|
+
* @param {import("fastify").FastifyRequest} req
|
|
26
|
+
* @param {import("fastify").FastifyReply} reply
|
|
27
|
+
* @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
*/
|
|
30
|
+
export async function handleApiTranscodeSessionFragmentFarPost(req, reply, { hlsSessionManager }) {
|
|
31
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
32
|
+
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
33
|
+
const sn = Number(body.sn);
|
|
34
|
+
const track = typeof body.track === "string" ? body.track : "";
|
|
35
|
+
const fragStartSec = Number(body.fragStartSec);
|
|
36
|
+
const bufferEndSec = Number(body.bufferEndSec);
|
|
37
|
+
const currentTimeSec = Number(body.currentTimeSec);
|
|
38
|
+
if (
|
|
39
|
+
!sessionId ||
|
|
40
|
+
!Number.isInteger(sn) ||
|
|
41
|
+
sn < 0 ||
|
|
42
|
+
!Number.isFinite(fragStartSec) ||
|
|
43
|
+
!Number.isFinite(bufferEndSec) ||
|
|
44
|
+
!Number.isFinite(currentTimeSec)
|
|
45
|
+
) {
|
|
46
|
+
return reply.code(400).send({ error: "sn (integer >=0), fragStartSec, bufferEndSec and currentTimeSec are required." });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const recorded = hlsSessionManager.recordFragmentFar(sessionId, {
|
|
50
|
+
sn,
|
|
51
|
+
track,
|
|
52
|
+
fragStartSec,
|
|
53
|
+
bufferEndSec,
|
|
54
|
+
currentTimeSec
|
|
55
|
+
});
|
|
56
|
+
if (!recorded) {
|
|
57
|
+
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
58
|
+
}
|
|
59
|
+
return reply.code(204).send();
|
|
60
|
+
}
|
package/server.js
CHANGED
|
@@ -26,6 +26,7 @@ import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/
|
|
|
26
26
|
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
27
27
|
import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
|
|
28
28
|
import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
|
|
29
|
+
import { handleApiTranscodeSessionFragmentFarPost } from "./routes/api/transcode-sessions/fragment-far/post.js";
|
|
29
30
|
import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
|
|
30
31
|
import { handleStreamGet } from "./routes/stream/get.js";
|
|
31
32
|
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
@@ -244,6 +245,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
244
245
|
app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
|
|
245
246
|
handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
|
|
246
247
|
);
|
|
248
|
+
app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
|
|
249
|
+
handleApiTranscodeSessionFragmentFarPost(req, reply, { hlsSessionManager })
|
|
250
|
+
);
|
|
247
251
|
app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
|
|
248
252
|
handleApiTranscodeSessionSeekPost(req, reply, { hlsSessionManager })
|
|
249
253
|
);
|
|
@@ -152,5 +152,16 @@ export async function readAviKeyframeTimes(readRange, fileSize) {
|
|
|
152
152
|
}
|
|
153
153
|
videoFrame += 1;
|
|
154
154
|
}
|
|
155
|
-
|
|
155
|
+
if (times.length === 0) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
// AVI names a keyframe by its FRAME NUMBER, and the time above is that number
|
|
159
|
+
// multiplied by the frame duration the header declares. The frames are the
|
|
160
|
+
// right ones — measured 2026-08-21 against the files themselves, 1196 index
|
|
161
|
+
// entries against 1196 real keyframes and 901 against 901, exactly — but the
|
|
162
|
+
// names are 10-44 ms away from the presentation times the demuxer computes,
|
|
163
|
+
// always under one frame. So the caller is told how far a time here may be
|
|
164
|
+
// from the instant it refers to, and can ask for a seek late enough that it
|
|
165
|
+
// still lands on the frame rather than on the one before it.
|
|
166
|
+
return { times, tolerance: secondsPerFrame };
|
|
156
167
|
}
|
|
@@ -62,7 +62,7 @@ const READERS = [
|
|
|
62
62
|
* @param {ReadRange} params.readRange
|
|
63
63
|
* @param {number} params.fileSize
|
|
64
64
|
* @param {string} [params.label] - For logging only.
|
|
65
|
-
* @returns {Promise<{ times: number[] | null, format: string }>} Ascending
|
|
65
|
+
* @returns {Promise<{ times: number[] | null, format: string, tolerance: number }>} Ascending
|
|
66
66
|
* seconds, or null times when this file has no readable index — the caller
|
|
67
67
|
* must then not claim to know the grid. The format is which reader matched,
|
|
68
68
|
* reported whether or not it produced anything: how often an index disagrees
|
|
@@ -71,27 +71,39 @@ const READERS = [
|
|
|
71
71
|
*/
|
|
72
72
|
export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
73
73
|
if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
|
|
74
|
-
return { times: null, format: "unknown" };
|
|
74
|
+
return { times: null, format: "unknown", tolerance: 0 };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
const startedAt = Date.now();
|
|
78
78
|
let times = null;
|
|
79
79
|
let format = "unrecognised";
|
|
80
|
+
// How far a time in `times` may be from the instant it names. Zero wherever
|
|
81
|
+
// the container states instants outright, which Matroska and MP4 both do.
|
|
82
|
+
let tolerance = 0;
|
|
80
83
|
try {
|
|
81
84
|
const sniff = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
|
|
82
85
|
if (!sniff) {
|
|
83
|
-
return { times: null, format: "unread" };
|
|
86
|
+
return { times: null, format: "unread", tolerance: 0 };
|
|
84
87
|
}
|
|
85
88
|
const reader = READERS.find((candidate) => candidate.matches(sniff));
|
|
86
89
|
if (reader) {
|
|
87
90
|
format = reader.name;
|
|
88
|
-
|
|
91
|
+
const read = await reader.read(readRange, fileSize);
|
|
92
|
+
// A reader may answer with the times alone, or with how far those times
|
|
93
|
+
// may sit from the instants they name — which only AVI has to say,
|
|
94
|
+
// because only AVI computes them from frame numbers.
|
|
95
|
+
if (Array.isArray(read)) {
|
|
96
|
+
times = read;
|
|
97
|
+
} else if (read && Array.isArray(read.times)) {
|
|
98
|
+
times = read.times;
|
|
99
|
+
tolerance = Number.isFinite(read.tolerance) ? read.tolerance : 0;
|
|
100
|
+
}
|
|
89
101
|
}
|
|
90
102
|
} catch (error) {
|
|
91
103
|
// A malformed or partially-downloaded index must never take playback down —
|
|
92
104
|
// it only means the grid is unknown, which the caller already handles.
|
|
93
105
|
logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
|
|
94
|
-
return { times: null, format };
|
|
106
|
+
return { times: null, format, tolerance: 0 };
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
const elapsedMs = Date.now() - startedAt;
|
|
@@ -102,5 +114,5 @@ export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
|
|
|
102
114
|
} else {
|
|
103
115
|
logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
|
|
104
116
|
}
|
|
105
|
-
return { times, format };
|
|
117
|
+
return { times, format, tolerance };
|
|
106
118
|
}
|
|
@@ -422,6 +422,12 @@ const LOOKAHEAD_RESUME_SECONDS = 60;
|
|
|
422
422
|
// encoding 125 s of content before reaching the viewer's position. Field
|
|
423
423
|
// 2026-08-02: a seek took 56 s, of which ~50 s was this backoff.
|
|
424
424
|
const SEEK_BACKOFF_SEGMENTS = 1;
|
|
425
|
+
|
|
426
|
+
// How many produced segments' true start times to remember, so a player's
|
|
427
|
+
// report about one of them can be answered. Two hundred is about twenty
|
|
428
|
+
// minutes of playback at these segment lengths — far more than the recent past
|
|
429
|
+
// a stall report can be about, and small enough to be free.
|
|
430
|
+
const TRUE_START_MEMORY = 200;
|
|
425
431
|
// How long to wait for a scrub to stop moving before acting on it. Small,
|
|
426
432
|
// because the browser already collapses a drag into ONE report
|
|
427
433
|
// (`SEEK_REPORT_DEBOUNCE_MS`, 300 ms) and only reports where it settled — this
|
|
@@ -1176,6 +1182,54 @@ export function segmentCutTimesFrom(boundaries, startIndex) {
|
|
|
1176
1182
|
return times;
|
|
1177
1183
|
}
|
|
1178
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* How far the live boundary table has moved from the one the player holds, said
|
|
1187
|
+
* in words.
|
|
1188
|
+
*
|
|
1189
|
+
* The corrections are applied one boundary at a time and each is small enough
|
|
1190
|
+
* to look harmless; what nobody was watching is the total. It matters because a
|
|
1191
|
+
* run positioned on one table and cut on the other carries their difference into
|
|
1192
|
+
* every cut it makes — the fault of 2026-08-21, where the distance reached two
|
|
1193
|
+
* whole segments after one seek and four after the next. Printed beside each
|
|
1194
|
+
* correction so the total is visible while it is still small.
|
|
1195
|
+
*
|
|
1196
|
+
* @param {number[]} published - The table the playlist text was written from.
|
|
1197
|
+
* @param {number[]} live - The table corrected from produced segments.
|
|
1198
|
+
* @returns {string} A phrase, always readable, never throwing on odd input.
|
|
1199
|
+
*/
|
|
1200
|
+
export function describeGridDrift(published, live) {
|
|
1201
|
+
if (!Array.isArray(published) || !Array.isArray(live) || published.length === 0) {
|
|
1202
|
+
return "not comparable";
|
|
1203
|
+
}
|
|
1204
|
+
if (published.length !== live.length) {
|
|
1205
|
+
return `a different length (${published.length} against ${live.length})`;
|
|
1206
|
+
}
|
|
1207
|
+
let apart = 0;
|
|
1208
|
+
let worst = 0;
|
|
1209
|
+
let worstAt = -1;
|
|
1210
|
+
for (let index = 0; index < published.length; index += 1) {
|
|
1211
|
+
// Rounded to the millisecond BEFORE comparing, not only before printing.
|
|
1212
|
+
// Two boundaries moved by the same amount differ in the last bits of a
|
|
1213
|
+
// double, so an unrounded comparison picks between them by an accident
|
|
1214
|
+
// invisible in the printed figure — and the line would name a boundary the
|
|
1215
|
+
// reader cannot tell apart from the one before it. Rounded, ties keep the
|
|
1216
|
+
// earliest, which is also the one worth looking at first.
|
|
1217
|
+
const distance = Math.round(Math.abs(live[index] - published[index]) * 1000) / 1000;
|
|
1218
|
+
if (distance <= 0.001) {
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
apart += 1;
|
|
1222
|
+
if (distance > worst) {
|
|
1223
|
+
worst = distance;
|
|
1224
|
+
worstAt = index;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
if (apart === 0) {
|
|
1228
|
+
return "identical";
|
|
1229
|
+
}
|
|
1230
|
+
return `${apart} of ${published.length} boundaries apart, worst ${worst.toFixed(3)}s at #${worstAt}`;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1179
1233
|
/**
|
|
1180
1234
|
* How much later than a keyframe to ASK, so that ffmpeg lands on that keyframe.
|
|
1181
1235
|
*
|
|
@@ -1196,12 +1250,17 @@ export function seekLandingOffsetFor(session, keyframe) {
|
|
|
1196
1250
|
if (session?.transcodeVideo === true) {
|
|
1197
1251
|
return 0;
|
|
1198
1252
|
}
|
|
1253
|
+
// A grid whose times are approximate needs that error added on top, or a name
|
|
1254
|
+
// sitting just below its real keyframe seeks to before it and lands on the
|
|
1255
|
+
// one before that. Only AVI declares one.
|
|
1256
|
+
const tolerance = Number.isFinite(session?.keyframeTolerance) ? Math.max(0, session.keyframeTolerance) : 0;
|
|
1257
|
+
const wanted = SEEK_LANDING_OFFSET_SEC + tolerance;
|
|
1199
1258
|
const times = Array.isArray(session?.keyframeTimes) ? session.keyframeTimes : [];
|
|
1200
1259
|
const next = times.find((time) => time > keyframe + 0.001);
|
|
1201
1260
|
if (next === undefined) {
|
|
1202
|
-
return
|
|
1261
|
+
return wanted;
|
|
1203
1262
|
}
|
|
1204
|
-
return Math.min(
|
|
1263
|
+
return Math.min(wanted, (next - keyframe) / 2);
|
|
1205
1264
|
}
|
|
1206
1265
|
|
|
1207
1266
|
/**
|
|
@@ -1746,6 +1805,13 @@ export class HlsSessionManager {
|
|
|
1746
1805
|
// branches; on failure both fall back to their current behaviour (uniform
|
|
1747
1806
|
// grid for boundaries, raw target for seeking) — no regression.
|
|
1748
1807
|
let keyframeTimes = null;
|
|
1808
|
+
// How far a time in `keyframeTimes` may sit from the instant it names. Only
|
|
1809
|
+
// AVI has anything to declare here: it stores frame NUMBERS and the time is
|
|
1810
|
+
// that number times the frame duration, which lands 10-44 ms from the
|
|
1811
|
+
// presentation time the demuxer computes (measured 2026-08-21). A seek made
|
|
1812
|
+
// at such a name can fall just BELOW the real keyframe and land on the one
|
|
1813
|
+
// before it, which is the same fault the landing offset exists for.
|
|
1814
|
+
let keyframeTolerance = 0;
|
|
1749
1815
|
let keyframeMs = -1; // -1 = not run (skipped), -2 = running in the background
|
|
1750
1816
|
// Which container supplied the index, carried so the accuracy summary can
|
|
1751
1817
|
// say what it is a summary OF.
|
|
@@ -1758,6 +1824,9 @@ export class HlsSessionManager {
|
|
|
1758
1824
|
if (inheritedGrid) {
|
|
1759
1825
|
keyframeTimes = inheritedGrid.keyframeTimes;
|
|
1760
1826
|
containerFormat = inheritedGrid.containerFormat ?? "";
|
|
1827
|
+
keyframeTolerance = Number.isFinite(inheritedGrid.keyframeTolerance)
|
|
1828
|
+
? inheritedGrid.keyframeTolerance
|
|
1829
|
+
: 0;
|
|
1761
1830
|
} else if (hasDuration && !transcodeVideo && !audioOnly) {
|
|
1762
1831
|
// Video-COPY path: keyframeTimes are REQUIRED to build correct segment
|
|
1763
1832
|
// boundaries (the playlist itself), so this MUST block session creation —
|
|
@@ -1779,11 +1848,28 @@ export class HlsSessionManager {
|
|
|
1779
1848
|
const index = await this.#readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName });
|
|
1780
1849
|
keyframeTimes = index.times;
|
|
1781
1850
|
containerFormat = index.format;
|
|
1851
|
+
keyframeTolerance = Number.isFinite(index.tolerance) ? index.tolerance : 0;
|
|
1782
1852
|
keyframeMs = Date.now() - keyframeStartMs;
|
|
1783
1853
|
if (!keyframeTimes) {
|
|
1854
|
+
// No index, so there is no honest grid for a COPY: a copied picture can
|
|
1855
|
+
// only be cut at the source's own keyframes, and we do not know where
|
|
1856
|
+
// they are. Declaring an even grid instead is a falsehood the player
|
|
1857
|
+
// punishes — it walks the whole file to rebuild the timeline, or shows
|
|
1858
|
+
// audio with no picture because a segment begins with nothing
|
|
1859
|
+
// decodable (both field-observed 2026-08-02).
|
|
1860
|
+
//
|
|
1861
|
+
// Re-encoding is the honest answer and costs an encoder: keyframes are
|
|
1862
|
+
// then PLACED at our own cut times rather than found, so the grid is
|
|
1863
|
+
// correct by construction whatever the container. MPEG-TS is the case
|
|
1864
|
+
// this exists for — measured 2026-08-21, 669 real keyframes and no
|
|
1865
|
+
// index of any kind to read them from — and a container whose index
|
|
1866
|
+
// could not be read in the budget lands here too, which is right for
|
|
1867
|
+
// the same reason.
|
|
1868
|
+
transcodeVideo = true;
|
|
1784
1869
|
logger.warn(
|
|
1785
|
-
`transcode ${sessionId}: no
|
|
1786
|
-
`
|
|
1870
|
+
`transcode ${sessionId}: no keyframe index in the ${containerFormat} container for ` +
|
|
1871
|
+
`"${logName}" — a copied picture has no honest grid without one, so the video is ` +
|
|
1872
|
+
"re-encoded instead and its keyframes are placed on our own cuts"
|
|
1787
1873
|
);
|
|
1788
1874
|
}
|
|
1789
1875
|
} else if (hasDuration && transcodeVideo) {
|
|
@@ -2035,6 +2121,9 @@ export class HlsSessionManager {
|
|
|
2035
2121
|
// KNOWN valid position instead of trusting the container's own on-the-fly
|
|
2036
2122
|
// seek at an arbitrary target — see the probe call above for why.
|
|
2037
2123
|
keyframeTimes,
|
|
2124
|
+
// How far those times may sit from the instants they name — nonzero only
|
|
2125
|
+
// for AVI, which computes them from frame numbers.
|
|
2126
|
+
keyframeTolerance,
|
|
2038
2127
|
// Which container the index came from, and how well it has held up. The
|
|
2039
2128
|
// cut times of a copied video ARE its index, and an index can be wrong —
|
|
2040
2129
|
// measured 2026-08-06, one claimed a keyframe four seconds from where the
|
|
@@ -2515,6 +2604,22 @@ export class HlsSessionManager {
|
|
|
2515
2604
|
: (session.segmentBoundaries ?? []);
|
|
2516
2605
|
}
|
|
2517
2606
|
|
|
2607
|
+
/**
|
|
2608
|
+
* Where a run beginning at `index` must be positioned: the time the PLAYER
|
|
2609
|
+
* was told that segment starts at.
|
|
2610
|
+
*
|
|
2611
|
+
* Public because it is the invariant this class has broken twice, and a
|
|
2612
|
+
* private one cannot be pinned by a test. It must always be the table the cut
|
|
2613
|
+
* list is taken from — see the comment where a run is started.
|
|
2614
|
+
*
|
|
2615
|
+
* @param {HlsSession} session
|
|
2616
|
+
* @param {number} index
|
|
2617
|
+
* @returns {number}
|
|
2618
|
+
*/
|
|
2619
|
+
runStartTimeFor(session, index) {
|
|
2620
|
+
return this.#publishedStartTime(session, index);
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2518
2623
|
#publishedStartTime(session, index) {
|
|
2519
2624
|
const boundaries = Array.isArray(session.publishedBoundaries) && session.publishedBoundaries.length > 0
|
|
2520
2625
|
? session.publishedBoundaries
|
|
@@ -2674,6 +2779,69 @@ export class HlsSessionManager {
|
|
|
2674
2779
|
return true;
|
|
2675
2780
|
}
|
|
2676
2781
|
|
|
2782
|
+
/**
|
|
2783
|
+
* Answer the player's report that a delivered fragment sits far from the edge
|
|
2784
|
+
* of its buffer, with the one fact only this side holds: which boundary the
|
|
2785
|
+
* segment of that number really begins at.
|
|
2786
|
+
*
|
|
2787
|
+
* The player can say the gap; it cannot say whether the cause is its own
|
|
2788
|
+
* loading or a run whose output no longer matches its numbering. Here both
|
|
2789
|
+
* are in hand — the time the playlist gave that segment, and, when the
|
|
2790
|
+
* segment has been served, the time it truly began at — so the line either
|
|
2791
|
+
* names a shifted run or clears this side of it.
|
|
2792
|
+
*
|
|
2793
|
+
* Diagnostic only: nothing is repositioned on the strength of a browser's
|
|
2794
|
+
* reading, deliberately, because a wrong answer here would restart an encoder
|
|
2795
|
+
* the viewer is waiting on.
|
|
2796
|
+
*
|
|
2797
|
+
* @param {string} sessionId
|
|
2798
|
+
* @param {{ sn: number, track?: string, fragStartSec: number, bufferEndSec: number, currentTimeSec: number }} report
|
|
2799
|
+
* @returns {boolean} False when no such session exists.
|
|
2800
|
+
*/
|
|
2801
|
+
recordFragmentFar(sessionId, { sn, track, fragStartSec, bufferEndSec, currentTimeSec }) {
|
|
2802
|
+
const named = this.sessionsById.get(sessionId);
|
|
2803
|
+
if (!named || named.state === "disposed") {
|
|
2804
|
+
return false;
|
|
2805
|
+
}
|
|
2806
|
+
// Which of the two streams the report is about. The browser addresses
|
|
2807
|
+
// everything to the video session's id — the soundtrack is served under
|
|
2808
|
+
// `/a/<n>/` on that same id — but it is a session of its own, with its own
|
|
2809
|
+
// run and its own position, and that is exactly the pair this report exists
|
|
2810
|
+
// to tell apart. Answering an audio report from the picture's records would
|
|
2811
|
+
// state, confidently, something about the wrong stream.
|
|
2812
|
+
const onScreen = this.#activeVariant(named);
|
|
2813
|
+
const session = track === "audio"
|
|
2814
|
+
? ([...this.#familyOf(onScreen)].find((member) => member.audioOnly === true) ?? onScreen)
|
|
2815
|
+
: onScreen;
|
|
2816
|
+
const gap = fragStartSec - bufferEndSec;
|
|
2817
|
+
const declared = this.#publishedStartTime(session, sn);
|
|
2818
|
+
const trueStart = session.trueStartByIndex instanceof Map ? session.trueStartByIndex.get(sn) : undefined;
|
|
2819
|
+
const verdict = trueStart === undefined
|
|
2820
|
+
// Where a segment truly began is only ever read off one that was cut on
|
|
2821
|
+
// an explicit list — a uniform grid has nothing to read back — so this is
|
|
2822
|
+
// "not recorded", which is not the same as "not produced", and the line
|
|
2823
|
+
// must not claim the second.
|
|
2824
|
+
? "where that segment began is not recorded on this side, so the gap cannot be attributed here"
|
|
2825
|
+
: (() => {
|
|
2826
|
+
const at = this.#boundaryIndexAt(session, trueStart, this.publishedGridFor(session));
|
|
2827
|
+
if (at === null) {
|
|
2828
|
+
return `it really began at ${trueStart.toFixed(3)}s, which is no boundary of this grid`;
|
|
2829
|
+
}
|
|
2830
|
+
if (at === sn) {
|
|
2831
|
+
return `it really began at boundary #${sn}, where it should — the gap is not this run's`;
|
|
2832
|
+
}
|
|
2833
|
+
return `it really began at boundary #${at}, ${sn - at} place(s) before its own number — ` +
|
|
2834
|
+
"this run's output does not match its numbering";
|
|
2835
|
+
})();
|
|
2836
|
+
logger.warn(
|
|
2837
|
+
`transcode ${session.id} the player is stuck: ${session.audioOnly === true ? "sound" : "picture"} ` +
|
|
2838
|
+
`fragment #${sn} starts ${gap.toFixed(1)}s past ` +
|
|
2839
|
+
`the end of its buffer (${bufferEndSec.toFixed(1)}s, viewer at ${currentTimeSec.toFixed(1)}s, ` +
|
|
2840
|
+
`the playlist puts it at ${declared.toFixed(3)}s) — ${verdict}`
|
|
2841
|
+
);
|
|
2842
|
+
return true;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2677
2845
|
/**
|
|
2678
2846
|
* Observed produced bitrate (Mbit/s) averaged over the last few COMPLETED
|
|
2679
2847
|
* segment files (the newest file may still be being written and is
|
|
@@ -3721,7 +3889,7 @@ export class HlsSessionManager {
|
|
|
3721
3889
|
const head = session.encodeStartIndex;
|
|
3722
3890
|
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
3723
3891
|
? session.progress.processedSeconds
|
|
3724
|
-
: this
|
|
3892
|
+
: this.runStartTimeFor(session, head);
|
|
3725
3893
|
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
3726
3894
|
const boundLabel =
|
|
3727
3895
|
bound === "link" ? "viewer-link-bound" : bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
|
|
@@ -3763,9 +3931,18 @@ export class HlsSessionManager {
|
|
|
3763
3931
|
*
|
|
3764
3932
|
* @param {HlsSession} session
|
|
3765
3933
|
* @param {number} startIndex
|
|
3934
|
+
* @param {number} [positionSecondsOverride] - Begin the run at this instant
|
|
3935
|
+
* instead of at the time the playlist gives `startIndex`, keeping the
|
|
3936
|
+
* numbering and the cut list on the published grid. The one caller is the
|
|
3937
|
+
* realignment below: a COPIED picture cannot cut anywhere but at the
|
|
3938
|
+
* source's own keyframes, so its segment #N begins where the file says and
|
|
3939
|
+
* not where the grid does — and the sound that plays with it has to begin
|
|
3940
|
+
* at that same instant, or the two are apart by the difference. The output
|
|
3941
|
+
* is still labelled from the source clock (`-copyts`) and stamped on serve,
|
|
3942
|
+
* so the player sees both at the time the playlist names.
|
|
3766
3943
|
* @returns {Promise<void>}
|
|
3767
3944
|
*/
|
|
3768
|
-
async #startEncodeRun(session, startIndex) {
|
|
3945
|
+
async #startEncodeRun(session, startIndex, positionSecondsOverride) {
|
|
3769
3946
|
// A new run starts its own reckoning: a pair spanning the restart would
|
|
3770
3947
|
// count the gap between two runs as slow encoding.
|
|
3771
3948
|
session.learnSample = null;
|
|
@@ -3864,8 +4041,30 @@ export class HlsSessionManager {
|
|
|
3864
4041
|
}
|
|
3865
4042
|
|
|
3866
4043
|
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
3867
|
-
// 0-based output time of this segment, from the
|
|
3868
|
-
|
|
4044
|
+
// 0-based output time of this segment, from the table the PLAYER holds —
|
|
4045
|
+
// the same one the cut list below is taken from.
|
|
4046
|
+
//
|
|
4047
|
+
// These two were read from different tables until 2026-08-21, and that is
|
|
4048
|
+
// one fault, not two: `-segment_times` are measured from wherever the run
|
|
4049
|
+
// really began, so any distance between the position and the cut list moves
|
|
4050
|
+
// EVERY cut of that run by it. The live table keeps being corrected as
|
|
4051
|
+
// produced segments reveal where the file's cuts truly are, and those
|
|
4052
|
+
// corrections run backwards, so each restart began a little earlier than
|
|
4053
|
+
// the grid the cuts were stated on — and since the corrections accumulate,
|
|
4054
|
+
// so did the distance. Measured on `JUFD665.mp4`: after one seek restart a
|
|
4055
|
+
// produced segment held the boundary two places before its own number
|
|
4056
|
+
// (16.684 s, exactly 2.0000 segments), after the next it held the one four
|
|
4057
|
+
// places before (33.5 s). The player's buffer then stops extending at all,
|
|
4058
|
+
// because the content of every fragment lands before the time its playlist
|
|
4059
|
+
// entry names: `bufferEnd` stood still at 4571.1 s through four `frag-far`
|
|
4060
|
+
// warnings until hls.js gave up and jumped the viewer 16.8 s forward.
|
|
4061
|
+
//
|
|
4062
|
+
// 2.45.0 moved the CUT LIST onto the published table for this same reason
|
|
4063
|
+
// and left the position on the live one. Both belong on the published
|
|
4064
|
+
// table: a run must begin where the player was told the segment begins.
|
|
4065
|
+
const startSeconds = Number.isFinite(positionSecondsOverride)
|
|
4066
|
+
? positionSecondsOverride
|
|
4067
|
+
: this.runStartTimeFor(session, safeIndex);
|
|
3869
4068
|
const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
|
|
3870
4069
|
// Cut where this session's grid says, whoever is producing the frames. The
|
|
3871
4070
|
// times are measured from the start of THIS run; the same list serves as
|
|
@@ -4157,6 +4356,17 @@ export class HlsSessionManager {
|
|
|
4157
4356
|
`(+${Date.now() - restartEnteredAt}ms since the restart was asked for) ` +
|
|
4158
4357
|
`(${formatSeconds(startSeconds)}) "${session.fileName}"`
|
|
4159
4358
|
);
|
|
4359
|
+
// The four numbers a run is positioned by, said once, because their
|
|
4360
|
+
// disagreement is invisible everywhere else. The two tables are printed
|
|
4361
|
+
// side by side: while they differ, every cut of this run is off by the
|
|
4362
|
+
// difference, and nothing downstream can tell that from a bad index.
|
|
4363
|
+
const liveStart = this.#segmentStartTime(session, safeIndex);
|
|
4364
|
+
logger.info(
|
|
4365
|
+
`transcode ${session.id} ${session.runLabel} positioned at ${startSeconds.toFixed(3)}s ` +
|
|
4366
|
+
`for boundary #${safeIndex} (published ${startSeconds.toFixed(3)}s, ` +
|
|
4367
|
+
`live ${liveStart.toFixed(3)}s, apart ${(liveStart - startSeconds).toFixed(3)}s), ` +
|
|
4368
|
+
`numbering from #${safeIndex}`
|
|
4369
|
+
);
|
|
4160
4370
|
|
|
4161
4371
|
this.#wireEncodeProcess(session, ffmpeg);
|
|
4162
4372
|
}
|
|
@@ -4476,7 +4686,7 @@ export class HlsSessionManager {
|
|
|
4476
4686
|
// request just ahead of the live edge.
|
|
4477
4687
|
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
4478
4688
|
? session.progress.processedSeconds
|
|
4479
|
-
: this
|
|
4689
|
+
: this.runStartTimeFor(session, head);
|
|
4480
4690
|
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
4481
4691
|
const withinWindow = index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS;
|
|
4482
4692
|
if (withinWindow) {
|
|
@@ -4769,7 +4979,7 @@ export class HlsSessionManager {
|
|
|
4769
4979
|
const head = session.encodeStartIndex;
|
|
4770
4980
|
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
4771
4981
|
? session.progress.processedSeconds
|
|
4772
|
-
: this
|
|
4982
|
+
: this.runStartTimeFor(session, head);
|
|
4773
4983
|
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
4774
4984
|
// Already covered by the running encode — the data is on its way, so
|
|
4775
4985
|
// restarting would only destroy work the viewer is waiting for. The run has
|
|
@@ -5205,7 +5415,17 @@ export class HlsSessionManager {
|
|
|
5205
5415
|
return;
|
|
5206
5416
|
}
|
|
5207
5417
|
session.landingReportedForRun = index;
|
|
5208
|
-
|
|
5418
|
+
// What the run was ASKED for, taken from the run itself rather than looked
|
|
5419
|
+
// up again in a table. The two used to be the same lookup; they stopped
|
|
5420
|
+
// being so when a run began positioning on the published grid while this
|
|
5421
|
+
// read the live one, which made a perfect landing report a drift equal to
|
|
5422
|
+
// the distance between the tables — and cancelled a real landing error of
|
|
5423
|
+
// the same size to zero. A run also has one legitimate position that is in
|
|
5424
|
+
// no table at all: the realignment that starts the sound where the copied
|
|
5425
|
+
// picture truly begins.
|
|
5426
|
+
const asked = Number.isFinite(session.progress?.startPositionSeconds)
|
|
5427
|
+
? session.progress.startPositionSeconds
|
|
5428
|
+
: this.runStartTimeFor(session, index);
|
|
5209
5429
|
const drift = trueStart - asked;
|
|
5210
5430
|
if (!Number.isFinite(drift) || Math.abs(drift) <= PLAYER_BUFFER_HOLE_SEC) {
|
|
5211
5431
|
return;
|
|
@@ -5220,6 +5440,18 @@ export class HlsSessionManager {
|
|
|
5220
5440
|
#noteIndexAccuracy(session, index, trueStart, declaredStart) {
|
|
5221
5441
|
const deviation = Math.abs(trueStart - declaredStart);
|
|
5222
5442
|
session.indexCheck ??= newIndexCheck();
|
|
5443
|
+
// Where each produced segment truly began, kept so that a player reporting
|
|
5444
|
+
// a stall can be ANSWERED rather than merely believed. Bounded: only the
|
|
5445
|
+
// recent past can be the subject of such a report, and an unbounded map on
|
|
5446
|
+
// a two-hour film is a leak.
|
|
5447
|
+
session.trueStartByIndex ??= new Map();
|
|
5448
|
+
session.trueStartByIndex.set(index, trueStart);
|
|
5449
|
+
if (session.trueStartByIndex.size > TRUE_START_MEMORY) {
|
|
5450
|
+
const oldest = session.trueStartByIndex.keys().next();
|
|
5451
|
+
if (!oldest.done) {
|
|
5452
|
+
session.trueStartByIndex.delete(oldest.value);
|
|
5453
|
+
}
|
|
5454
|
+
}
|
|
5223
5455
|
// Did this segment begin at ANOTHER keyframe from the same list? Half an
|
|
5224
5456
|
// audio frame is the tolerance — anything the list names is exact, so a
|
|
5225
5457
|
// match is a match. `keyframeTimes` is the list the grid was built from, so
|
|
@@ -5264,7 +5496,18 @@ export class HlsSessionManager {
|
|
|
5264
5496
|
// rung's segments no longer stand where the stream it accompanies
|
|
5265
5497
|
// would have put them. That is a broken splice, not a wrong index.
|
|
5266
5498
|
? "this rung did not cut where its grid says; a switch to it will not join cleanly"
|
|
5267
|
-
|
|
5499
|
+
// A copy. The two possible faults need opposite fixes and the
|
|
5500
|
+
// numbers already tell them apart, so the sentence follows THEM
|
|
5501
|
+
// rather than the branch it is printed from. Until 2026-08-21 it
|
|
5502
|
+
// blamed the index either way — including through a session whose
|
|
5503
|
+
// segments each held the boundary two, then four places before
|
|
5504
|
+
// their own number, which is this code's own definition of a fault
|
|
5505
|
+
// in this code. That sentence is what sent the reading of that
|
|
5506
|
+
// session after the file instead of after the arithmetic.
|
|
5507
|
+
: at === null
|
|
5508
|
+
? "the container's keyframe index disagrees with the file; using the file"
|
|
5509
|
+
: `this began at another boundary of the same list, ${index - at} place(s) before ` +
|
|
5510
|
+
"its own number — the numbering of this run is shifted, not the index")
|
|
5268
5511
|
);
|
|
5269
5512
|
}
|
|
5270
5513
|
}
|
|
@@ -5347,7 +5590,9 @@ export class HlsSessionManager {
|
|
|
5347
5590
|
}
|
|
5348
5591
|
logger.info(
|
|
5349
5592
|
`transcode ${session.id} boundary #${index} corrected ${wasAt.toFixed(3)}s → ` +
|
|
5350
|
-
`${trueStart.toFixed(3)}s from the file itself`
|
|
5593
|
+
`${trueStart.toFixed(3)}s from the file itself` +
|
|
5594
|
+
`, and the live table is now ${describeGridDrift(this.publishedGridFor(session), boundaries)}` +
|
|
5595
|
+
" from the one the player holds"
|
|
5351
5596
|
);
|
|
5352
5597
|
// And every OTHER member whose run begins at this very boundary is moved
|
|
5353
5598
|
// to the same instant.
|
|
@@ -5383,9 +5628,18 @@ export class HlsSessionManager {
|
|
|
5383
5628
|
// time: a seek decides by index, finds this run already begins at #index,
|
|
5384
5629
|
// and answers "already within the running encode" — which is true about
|
|
5385
5630
|
// the index and false about the instant, and it is why the first version
|
|
5386
|
-
// of this fix moved nothing at all.
|
|
5387
|
-
//
|
|
5388
|
-
|
|
5631
|
+
// of this fix moved nothing at all.
|
|
5632
|
+
//
|
|
5633
|
+
// The instant is passed EXPLICITLY. It used to be smuggled through the
|
|
5634
|
+
// live boundary table — this function had just written `trueStart` into
|
|
5635
|
+
// it, and the run read its position from there — which stopped working
|
|
5636
|
+
// the moment a run began positioning itself on the table the player
|
|
5637
|
+
// holds, as it now must. Smuggled, the restart would land exactly where
|
|
5638
|
+
// it already was: picture and sound would stay apart and a healthy audio
|
|
5639
|
+
// run would be discarded for nothing, which is the shape the field
|
|
5640
|
+
// already showed (eleven restarts in four minutes, eight of them dying
|
|
5641
|
+
// with `run had produced 0.0s`).
|
|
5642
|
+
void this.#startEncodeRun(member, index, trueStart).catch(() => {});
|
|
5389
5643
|
}
|
|
5390
5644
|
}
|
|
5391
5645
|
|
|
@@ -5468,8 +5722,14 @@ export class HlsSessionManager {
|
|
|
5468
5722
|
* @param {number} seconds
|
|
5469
5723
|
* @returns {number | null}
|
|
5470
5724
|
*/
|
|
5471
|
-
#boundaryIndexAt(session, seconds) {
|
|
5472
|
-
|
|
5725
|
+
#boundaryIndexAt(session, seconds, table) {
|
|
5726
|
+
// The table is nameable because the two answer different questions. The
|
|
5727
|
+
// LIVE one says "is this a cut this file actually has", which is what a
|
|
5728
|
+
// reading taken off a produced segment is about. The PUBLISHED one says "is
|
|
5729
|
+
// this a cut the player believes in", which is what a report from the player
|
|
5730
|
+
// is about. Answering one with the other prints an index from one grid
|
|
5731
|
+
// beside a time from the other.
|
|
5732
|
+
const boundaries = Array.isArray(table) ? table : session.segmentBoundaries;
|
|
5473
5733
|
if (!Array.isArray(boundaries)) {
|
|
5474
5734
|
return null;
|
|
5475
5735
|
}
|
|
@@ -6848,6 +7108,7 @@ export class HlsSessionManager {
|
|
|
6848
7108
|
// creations (field 2026-08-17, corrections of 0.6-2.9 s).
|
|
6849
7109
|
published: base.publishedBoundaries,
|
|
6850
7110
|
keyframeTimes: base.keyframeTimes,
|
|
7111
|
+
keyframeTolerance: base.keyframeTolerance,
|
|
6851
7112
|
containerFormat: base.containerFormat
|
|
6852
7113
|
}
|
|
6853
7114
|
: null,
|
|
@@ -7949,7 +8210,14 @@ export class HlsSessionManager {
|
|
|
7949
8210
|
// causes indistinguishable: an encoder waiting for torrent pieces looks
|
|
7950
8211
|
// exactly like one that is encoding and simply has not finished. The
|
|
7951
8212
|
// difference is whether its position has moved at all.
|
|
7952
|
-
|
|
8213
|
+
// Where this run began, from the run — the same reckoning
|
|
8214
|
+
// `processedSeconds` is counted in. A table lookup here can disagree with
|
|
8215
|
+
// it by the distance between the two grids, which is enough to print a
|
|
8216
|
+
// negative "produced" and send the reader after the torrent when the
|
|
8217
|
+
// encoder is the subject.
|
|
8218
|
+
const runStartSeconds = Number.isFinite(session.progress?.startPositionSeconds)
|
|
8219
|
+
? session.progress.startPositionSeconds
|
|
8220
|
+
: this.runStartTimeFor(session, session.encodeStartIndex ?? 0);
|
|
7953
8221
|
const position = Number(session.progress?.processedSeconds);
|
|
7954
8222
|
const produced = Number.isFinite(position) ? position - runStartSeconds : null;
|
|
7955
8223
|
const speed = session.progress?.speed ?? "n/a";
|
package/services/torrent-pool.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import crypto from "node:crypto";
|
|
10
|
+
import dns from "node:dns/promises";
|
|
10
11
|
import os from "node:os";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
import { rmSync, statfsSync } from "node:fs";
|
|
@@ -14,6 +15,39 @@ import WebTorrent from "webtorrent";
|
|
|
14
15
|
import { logger } from "../utils/logger.js";
|
|
15
16
|
import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
|
|
16
17
|
|
|
18
|
+
// The DHT's entry points. Two of the three the library ships answer nothing —
|
|
19
|
+
// measured 2026-08-21 from the addon host: `router.bittorrent.com` and
|
|
20
|
+
// `router.utorrent.com` did not reply to a hand-written `ping` at all, while a
|
|
21
|
+
// control datagram to a DNS server came back in 20 ms, so the silence is
|
|
22
|
+
// theirs. They stay in the list because they cost nothing and may come back;
|
|
23
|
+
// what the list needed was entries that answer today.
|
|
24
|
+
//
|
|
25
|
+
// The names are RESOLVED HERE, to IPv4, and the addresses are what the library
|
|
26
|
+
// is given. That is the second half of the fault: `dht.transmissionbt.com` is
|
|
27
|
+
// alive — it answered `find_node` with eight nodes — but on a host with global
|
|
28
|
+
// IPv6 its name resolves to an IPv6 address first, and the DHT's socket is
|
|
29
|
+
// IPv4, so by name the one live entry was never reached. Measured the same day:
|
|
30
|
+
// by name, 0 nodes after 21 s, every run; by address, 22 nodes in 5 s.
|
|
31
|
+
//
|
|
32
|
+
// Addresses are not written down. They rot exactly as the old list rotted.
|
|
33
|
+
const DHT_BOOTSTRAP_NAMES = [
|
|
34
|
+
"dht.transmissionbt.com:6881",
|
|
35
|
+
"dht.libtorrent.org:25401",
|
|
36
|
+
"router.bittorrent.com:6881",
|
|
37
|
+
"router.utorrent.com:6881"
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// How long after start a still-empty routing table is worth saying out loud.
|
|
41
|
+
// Bootstrapping takes seconds; a table empty after this is a list that has died
|
|
42
|
+
// and nobody has noticed, which is the state this host was found in.
|
|
43
|
+
const DHT_EMPTY_REPORT_MS = 60 * 1000;
|
|
44
|
+
|
|
45
|
+
// How long one bootstrap name may take to resolve. This is awaited before the
|
|
46
|
+
// torrent client is built, so it is time during which the thread answers
|
|
47
|
+
// nothing — and the DHT is best-effort, so a name that is slow to resolve is
|
|
48
|
+
// worth less than the delay of waiting for it.
|
|
49
|
+
const DHT_RESOLVE_TIMEOUT_MS = 2000;
|
|
50
|
+
|
|
17
51
|
// WebTorrent's default download root (see webtorrent lib/torrent.js: TMP =
|
|
18
52
|
// path.join(os.tmpdir(), 'webtorrent')). We use the default store, so all
|
|
19
53
|
// torrent data lives under here.
|
|
@@ -449,6 +483,103 @@ function isMagnetSource(torrentId) {
|
|
|
449
483
|
return typeof torrentId === "string" && /^magnet:\?/i.test(torrentId.trim());
|
|
450
484
|
}
|
|
451
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Split a `host` or `host:port` bootstrap entry.
|
|
488
|
+
*
|
|
489
|
+
* @param {unknown} entry
|
|
490
|
+
* @returns {{ host: string, port: number } | null} Null for anything that is
|
|
491
|
+
* not a usable entry, so a typo drops one node instead of failing the client.
|
|
492
|
+
*/
|
|
493
|
+
export function parseBootstrapEntry(entry) {
|
|
494
|
+
if (typeof entry !== "string") {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
const trimmed = entry.trim();
|
|
498
|
+
if (trimmed.length === 0) {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const colon = trimmed.lastIndexOf(":");
|
|
502
|
+
if (colon < 0) {
|
|
503
|
+
return { host: trimmed, port: 6881 };
|
|
504
|
+
}
|
|
505
|
+
const host = trimmed.slice(0, colon);
|
|
506
|
+
const port = Number(trimmed.slice(colon + 1));
|
|
507
|
+
if (host.length === 0 || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
return { host, port };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Turn bootstrap names into `address:port` entries, keeping only IPv4.
|
|
515
|
+
*
|
|
516
|
+
* Why the resolution happens here rather than being left to the library: the
|
|
517
|
+
* DHT's socket is IPv4, and a name that resolves to IPv6 first is silently
|
|
518
|
+
* unreachable through it — which is how a live bootstrap node came to look
|
|
519
|
+
* dead on this host. See {@link DHT_BOOTSTRAP_NAMES}.
|
|
520
|
+
*
|
|
521
|
+
* Best-effort by construction. A name that does not resolve is dropped and
|
|
522
|
+
* said; if none resolve the caller gets an empty list and the library keeps its
|
|
523
|
+
* own defaults, which is no worse than before.
|
|
524
|
+
*
|
|
525
|
+
* @param {string[]} [names]
|
|
526
|
+
* @param {number} [timeoutMs] - How long one name may take before it is
|
|
527
|
+
* treated as unresolvable.
|
|
528
|
+
* @returns {Promise<string[]>}
|
|
529
|
+
*/
|
|
530
|
+
export async function resolveDhtBootstrap(names = DHT_BOOTSTRAP_NAMES, timeoutMs = DHT_RESOLVE_TIMEOUT_MS) {
|
|
531
|
+
const entries = names.map(parseBootstrapEntry).filter((entry) => entry !== null);
|
|
532
|
+
const resolved = [];
|
|
533
|
+
const failed = [];
|
|
534
|
+
await Promise.all(
|
|
535
|
+
entries.map(async ({ host, port }) => {
|
|
536
|
+
try {
|
|
537
|
+
// Capped, because this is awaited before the torrent client exists and
|
|
538
|
+
// therefore before this thread will answer anything. `dns.resolve4`
|
|
539
|
+
// talks to the host's resolver with c-ares' own defaults — about five
|
|
540
|
+
// seconds times four tries — so a black-holing resolver would hold the
|
|
541
|
+
// whole worker for twenty seconds with nothing said. A name that does
|
|
542
|
+
// not answer inside the cap is treated exactly like one that fails:
|
|
543
|
+
// dropped, named, and the rest of the list stands.
|
|
544
|
+
const addresses = await Promise.race([
|
|
545
|
+
dns.resolve4(host),
|
|
546
|
+
new Promise((_resolve, reject) => {
|
|
547
|
+
const timer = setTimeout(() => reject(new Error("timed out")), timeoutMs);
|
|
548
|
+
timer.unref?.();
|
|
549
|
+
})
|
|
550
|
+
]);
|
|
551
|
+
for (const address of addresses) {
|
|
552
|
+
resolved.push(`${address}:${port}`);
|
|
553
|
+
}
|
|
554
|
+
} catch {
|
|
555
|
+
failed.push(`${host}:${port}`);
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
);
|
|
559
|
+
if (failed.length > 0) {
|
|
560
|
+
logger.warn(`torrent-pool: DHT bootstrap names that do not resolve to IPv4: ${failed.join(", ")}`);
|
|
561
|
+
}
|
|
562
|
+
return resolved;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* How many nodes the client's DHT knows, or null when it has no DHT at all.
|
|
567
|
+
*
|
|
568
|
+
* @param {{ dht?: { nodes?: { toArray?: () => unknown[] } } }} client
|
|
569
|
+
* @returns {number | null}
|
|
570
|
+
*/
|
|
571
|
+
export function dhtNodeCount(client) {
|
|
572
|
+
const nodes = client?.dht?.nodes;
|
|
573
|
+
if (!nodes || typeof nodes.toArray !== "function") {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
return nodes.toArray().length;
|
|
578
|
+
} catch {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
452
583
|
export class TorrentPool {
|
|
453
584
|
/**
|
|
454
585
|
* In-flight `client.add()` promises keyed by the same key as `torrents`.
|
|
@@ -521,13 +652,16 @@ export class TorrentPool {
|
|
|
521
652
|
/** Periodic adaptive-upload adjustment timer. */
|
|
522
653
|
#uploadAdjustTimer = null;
|
|
523
654
|
|
|
655
|
+
/** One-shot timer that reports the size of the DHT's routing table. */
|
|
656
|
+
#dhtReportTimer = null;
|
|
657
|
+
|
|
524
658
|
/**
|
|
525
659
|
* @param {{ maxDiskBytes?: number }} [options]
|
|
526
660
|
* `maxDiskBytes` caps total downloaded torrent data; when omitted a
|
|
527
661
|
* default is computed from free disk (min(10 GB, half free)). Pass 0 to
|
|
528
662
|
* disable the cap.
|
|
529
663
|
*/
|
|
530
|
-
constructor({ maxDiskBytes, memoryBytes } = {}) {
|
|
664
|
+
constructor({ maxDiskBytes, memoryBytes, dhtBootstrap } = {}) {
|
|
531
665
|
this.#memoryBytes = Number.isFinite(memoryBytes) && memoryBytes > 0 ? memoryBytes : undefined;
|
|
532
666
|
|
|
533
667
|
// Sweep orphaned torrent data left by a previous hard kill (no graceful
|
|
@@ -541,8 +675,33 @@ export class TorrentPool {
|
|
|
541
675
|
logger.warn(`torrent-pool: could not sweep orphaned store at startup: ${message}`);
|
|
542
676
|
}
|
|
543
677
|
|
|
678
|
+
const bootstrap = Array.isArray(dhtBootstrap) ? dhtBootstrap.filter(Boolean) : [];
|
|
679
|
+
if (bootstrap.length > 0) {
|
|
680
|
+
logger.info(`torrent-pool: DHT bootstrap nodes: ${bootstrap.join(", ")}`);
|
|
681
|
+
} else {
|
|
682
|
+
logger.warn("torrent-pool: no DHT bootstrap nodes resolved; the library's own list is all there is");
|
|
683
|
+
}
|
|
544
684
|
/** @type {import("webtorrent").WebTorrent} */
|
|
545
|
-
this.client = new WebTorrent();
|
|
685
|
+
this.client = new WebTorrent(bootstrap.length > 0 ? { dht: { bootstrap } } : undefined);
|
|
686
|
+
// A bootstrap list rots, and it rots silently: the one the library ships
|
|
687
|
+
// had two dead entries and a third that could not be reached by name, and
|
|
688
|
+
// nothing said so for as long as that was true. Say it once, late enough
|
|
689
|
+
// that a slow bootstrap is not mistaken for a dead one.
|
|
690
|
+
this.#dhtReportTimer = setTimeout(() => {
|
|
691
|
+
const nodes = dhtNodeCount(this.client);
|
|
692
|
+
if (nodes === null) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (nodes === 0) {
|
|
696
|
+
logger.warn(
|
|
697
|
+
"torrent-pool: the DHT knows no nodes a minute after start — its bootstrap list is not " +
|
|
698
|
+
"answering, so a torrent has only its trackers to find peers with"
|
|
699
|
+
);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
logger.info(`torrent-pool: the DHT knows ${nodes} nodes`);
|
|
703
|
+
}, DHT_EMPTY_REPORT_MS);
|
|
704
|
+
this.#dhtReportTimer.unref?.();
|
|
546
705
|
|
|
547
706
|
/**
|
|
548
707
|
* Active torrents keyed by `"${sourceType}:${sha1(source)}"`.
|
|
@@ -1681,6 +1840,10 @@ export class TorrentPool {
|
|
|
1681
1840
|
clearInterval(this.#uploadAdjustTimer);
|
|
1682
1841
|
this.#uploadAdjustTimer = null;
|
|
1683
1842
|
}
|
|
1843
|
+
if (this.#dhtReportTimer) {
|
|
1844
|
+
clearTimeout(this.#dhtReportTimer);
|
|
1845
|
+
this.#dhtReportTimer = null;
|
|
1846
|
+
}
|
|
1684
1847
|
// Cancel any pending idle-removal timers — destroyAll handles teardown.
|
|
1685
1848
|
for (const timer of this.#idleTimers.values()) {
|
|
1686
1849
|
clearTimeout(timer);
|
|
@@ -35,12 +35,19 @@ import { Command, Event } from "./protocol.js";
|
|
|
35
35
|
// would drag in WebTorrent — and with it the real `webrtc-polyfill` — before
|
|
36
36
|
// the hook above had a chance to register. Verified the hard way: with a static
|
|
37
37
|
// import the process still aborted, and the stack named the genuine polyfill.
|
|
38
|
-
const { TorrentPool } = await import("../torrent-pool.js");
|
|
38
|
+
const { TorrentPool, resolveDhtBootstrap } = await import("../torrent-pool.js");
|
|
39
39
|
const { collectStoreStats, findSharedStore } = await import("../piece-store/shared-piece-store.js");
|
|
40
40
|
|
|
41
|
+
// Resolved before the client exists, because the client builds its DHT in its
|
|
42
|
+
// own constructor and the addresses have to be in hand by then. Awaiting here
|
|
43
|
+
// costs the few milliseconds of a DNS answer, once, on a thread that has not
|
|
44
|
+
// been asked for anything yet.
|
|
45
|
+
const dhtBootstrap = await resolveDhtBootstrap();
|
|
46
|
+
|
|
41
47
|
const pool = new TorrentPool({
|
|
42
48
|
maxDiskBytes: workerData?.maxDiskBytes,
|
|
43
|
-
memoryBytes: workerData?.memoryBytes
|
|
49
|
+
memoryBytes: workerData?.memoryBytes,
|
|
50
|
+
dhtBootstrap
|
|
44
51
|
});
|
|
45
52
|
|
|
46
53
|
/** Torrents by sourceKey — the main thread names them, this thread owns them. */
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The DHT's entry points are given as addresses, not names.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-08-21 on the addon host. Two of the three bootstrap nodes the
|
|
5
|
+
* library ships answer nothing: `router.bittorrent.com` and `router.utorrent.com`
|
|
6
|
+
* did not reply to a hand-written `ping` at all, while a control datagram to a
|
|
7
|
+
* DNS server came back in 20 ms. The third, `dht.transmissionbt.com`, is alive —
|
|
8
|
+
* it answered `find_node` with eight nodes — but on a host with global IPv6 its
|
|
9
|
+
* name resolves to an IPv6 address first, and the DHT's socket is IPv4, so by
|
|
10
|
+
* name it was never reached. By name: 0 nodes after 21 s, every run. By address:
|
|
11
|
+
* 22 nodes in 5 s.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
|
|
17
|
+
import { dhtNodeCount, parseBootstrapEntry, resolveDhtBootstrap } from "../services/torrent-pool.js";
|
|
18
|
+
|
|
19
|
+
test("a bootstrap entry is split into host and port", () => {
|
|
20
|
+
assert.deepEqual(parseBootstrapEntry("dht.libtorrent.org:25401"), {
|
|
21
|
+
host: "dht.libtorrent.org",
|
|
22
|
+
port: 25401
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("an entry with no port gets the DHT's own", () => {
|
|
27
|
+
assert.deepEqual(parseBootstrapEntry("router.bittorrent.com"), {
|
|
28
|
+
host: "router.bittorrent.com",
|
|
29
|
+
port: 6881
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("an unusable entry is dropped rather than breaking the client", () => {
|
|
34
|
+
// One bad line in the list must cost that node and nothing else — the DHT is
|
|
35
|
+
// best-effort by nature and a typo here would otherwise take the whole client
|
|
36
|
+
// down at construction.
|
|
37
|
+
assert.equal(parseBootstrapEntry(""), null);
|
|
38
|
+
assert.equal(parseBootstrapEntry(" "), null);
|
|
39
|
+
assert.equal(parseBootstrapEntry("host:0"), null);
|
|
40
|
+
assert.equal(parseBootstrapEntry("host:70000"), null);
|
|
41
|
+
assert.equal(parseBootstrapEntry("host:not-a-port"), null);
|
|
42
|
+
assert.equal(parseBootstrapEntry(":6881"), null);
|
|
43
|
+
assert.equal(parseBootstrapEntry(null), null);
|
|
44
|
+
assert.equal(parseBootstrapEntry(42), null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the routing table's size is readable, and its absence is not an error", () => {
|
|
48
|
+
assert.equal(dhtNodeCount({ dht: { nodes: { toArray: () => [1, 2, 3] } } }), 3);
|
|
49
|
+
assert.equal(dhtNodeCount({ dht: { nodes: { toArray: () => [] } } }), 0);
|
|
50
|
+
// A client built without a DHT, or one whose internals moved: the difference
|
|
51
|
+
// between "no DHT" and "an empty DHT" is the whole point of the report, so
|
|
52
|
+
// the first must not be printed as the second.
|
|
53
|
+
assert.equal(dhtNodeCount({}), null);
|
|
54
|
+
assert.equal(dhtNodeCount({ dht: {} }), null);
|
|
55
|
+
assert.equal(dhtNodeCount(null), null);
|
|
56
|
+
assert.equal(dhtNodeCount({ dht: { nodes: { toArray: () => { throw new Error("gone"); } } } }), null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a name that will not resolve inside the cap is dropped, not waited on", async () => {
|
|
60
|
+
// The whole call is awaited before the torrent client exists, so a resolver
|
|
61
|
+
// that black-holes must cost the cap and not c-ares' own four tries.
|
|
62
|
+
const started = Date.now();
|
|
63
|
+
// `.invalid` is reserved by RFC 2606 and never resolves; the cap is what
|
|
64
|
+
// bounds the wait when a resolver answers slowly rather than quickly.
|
|
65
|
+
const resolved = await resolveDhtBootstrap(["nothing.invalid:6881"], 200);
|
|
66
|
+
assert.deepEqual(resolved, []);
|
|
67
|
+
assert.ok(Date.now() - started < 2000, "the cap, not the resolver, decided when to give up");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an empty list is an answer, not a failure", async () => {
|
|
71
|
+
assert.deepEqual(await resolveDhtBootstrap([], 200), []);
|
|
72
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A run is POSITIONED where the player was told the segment begins.
|
|
3
|
+
*
|
|
4
|
+
* The companion of `cuts-follow-published-grid`, and the half that was missing.
|
|
5
|
+
* 2.45.0 moved the cut list onto the published table and left the position on
|
|
6
|
+
* the live one — but `-segment_times` are measured from wherever the run really
|
|
7
|
+
* began, so any distance between the two moves EVERY cut of that run by it. The
|
|
8
|
+
* live table keeps being corrected, and the corrections run backwards, so each
|
|
9
|
+
* restart began a little earlier than the grid its cuts were stated on and the
|
|
10
|
+
* distance accumulated across restarts.
|
|
11
|
+
*
|
|
12
|
+
* Field 2026-08-21, `JUFD665.mp4` (MP4, copy path, index read cleanly): after
|
|
13
|
+
* one seek restart a produced segment held the boundary two places before its
|
|
14
|
+
* own number — 16.684 s, which is 2.0000 segments — and after the next restart,
|
|
15
|
+
* four places, 33.5 s. The player's buffer then stopped extending at all,
|
|
16
|
+
* because every fragment's content landed before the time its playlist entry
|
|
17
|
+
* named: `bufferEnd` stood still at 4571.1 s through four `frag-far` warnings
|
|
18
|
+
* until hls.js gave up and jumped the viewer 16.8 s forward.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import test from "node:test";
|
|
23
|
+
|
|
24
|
+
import { HlsSessionManager, describeGridDrift, segmentCutTimesFrom } from "../services/hls-session-manager.js";
|
|
25
|
+
|
|
26
|
+
/** What the playlist in the player's hands says. */
|
|
27
|
+
const PUBLISHED = [0, 8.342, 16.684, 25.026, 33.368, 41.71];
|
|
28
|
+
/** The same grid after produced segments moved two of its cuts backwards. */
|
|
29
|
+
const CORRECTED = [0, 8.342, 14.682, 25.026, 31.366, 41.71];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {{ manager: HlsSessionManager, session: object }}
|
|
33
|
+
*/
|
|
34
|
+
function sessionWithDriftedGrid() {
|
|
35
|
+
const manager = new HlsSessionManager({
|
|
36
|
+
enabled: true,
|
|
37
|
+
ffmpegBin: "ffmpeg",
|
|
38
|
+
localBindHost: "127.0.0.1",
|
|
39
|
+
localPort: 9090
|
|
40
|
+
});
|
|
41
|
+
const session = {
|
|
42
|
+
id: "picture",
|
|
43
|
+
segmentBoundaries: [...CORRECTED],
|
|
44
|
+
publishedBoundaries: [...PUBLISHED]
|
|
45
|
+
};
|
|
46
|
+
return { manager, session };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("a run starts at the time the player was told, not at the corrected one", () => {
|
|
50
|
+
const { manager, session } = sessionWithDriftedGrid();
|
|
51
|
+
assert.equal(manager.runStartTimeFor(session, 2), 16.684);
|
|
52
|
+
assert.notEqual(manager.runStartTimeFor(session, 2), session.segmentBoundaries[2]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("position and cut list come from the same table", () => {
|
|
56
|
+
const { manager, session } = sessionWithDriftedGrid();
|
|
57
|
+
const grid = manager.publishedGridFor(session);
|
|
58
|
+
const start = manager.runStartTimeFor(session, 2);
|
|
59
|
+
// The cut list is stated as offsets from where the run begins. Adding the
|
|
60
|
+
// position back must land on the published boundaries exactly — which is the
|
|
61
|
+
// property that was false while the two came from different tables.
|
|
62
|
+
const absolute = segmentCutTimesFrom(grid, 2).map((offset) => Number((start + offset).toFixed(3)));
|
|
63
|
+
assert.deepEqual(absolute, [25.026, 33.368]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a session that published no grid positions on the live one", () => {
|
|
67
|
+
const { manager } = sessionWithDriftedGrid();
|
|
68
|
+
const session = { id: "no-playlist", segmentBoundaries: [...CORRECTED], publishedBoundaries: [] };
|
|
69
|
+
assert.equal(manager.runStartTimeFor(session, 2), CORRECTED[2]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("an index beyond the table is clamped rather than returning nothing", () => {
|
|
73
|
+
const { manager, session } = sessionWithDriftedGrid();
|
|
74
|
+
assert.equal(manager.runStartTimeFor(session, 9999), PUBLISHED[PUBLISHED.length - 1]);
|
|
75
|
+
assert.equal(manager.runStartTimeFor(session, -3), PUBLISHED[0]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the drift between the two tables is stated in full", () => {
|
|
79
|
+
assert.equal(describeGridDrift(PUBLISHED, PUBLISHED), "identical");
|
|
80
|
+
assert.equal(
|
|
81
|
+
describeGridDrift(PUBLISHED, CORRECTED),
|
|
82
|
+
"2 of 6 boundaries apart, worst 2.002s at #2"
|
|
83
|
+
);
|
|
84
|
+
assert.equal(describeGridDrift(PUBLISHED, [0, 1]), "a different length (6 against 2)");
|
|
85
|
+
assert.equal(describeGridDrift(null, CORRECTED), "not comparable");
|
|
86
|
+
assert.equal(describeGridDrift([], []), "not comparable");
|
|
87
|
+
});
|
|
@@ -48,3 +48,29 @@ test("no keyframe list is still answered", () => {
|
|
|
48
48
|
assert.equal(seekLandingOffsetFor({ transcodeVideo: false }, 5), OFFSET);
|
|
49
49
|
assert.equal(seekLandingOffsetFor(null, 5), OFFSET);
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test("a grid whose times are approximate is asked for that much later again", () => {
|
|
53
|
+
// AVI names a keyframe by its frame NUMBER and the time is that number times
|
|
54
|
+
// the frame duration, so a name can sit just BELOW the keyframe it refers to
|
|
55
|
+
// — measured 2026-08-21, 10-44 ms out on two files, always under one frame.
|
|
56
|
+
// Asking at the name alone would seek to before the real keyframe and land on
|
|
57
|
+
// the one before that, which is the fault this offset exists for.
|
|
58
|
+
const session = {
|
|
59
|
+
transcodeVideo: false,
|
|
60
|
+
keyframeTolerance: 0.04,
|
|
61
|
+
keyframeTimes: [0, 4.004, 8.008, 12.012]
|
|
62
|
+
};
|
|
63
|
+
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET + 0.04);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("an exact grid claims no tolerance", () => {
|
|
67
|
+
// Matroska and MP4 state instants outright — measured the same day, nine
|
|
68
|
+
// files and 11 665 keyframes with not one disagreement.
|
|
69
|
+
const session = { transcodeVideo: false, keyframeTolerance: 0, keyframeTimes: [0, 4.004, 8.008] };
|
|
70
|
+
assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the bound still holds once a tolerance is added", () => {
|
|
74
|
+
const session = { transcodeVideo: false, keyframeTolerance: 1, keyframeTimes: [0, 0.1, 0.2] };
|
|
75
|
+
assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
|
|
76
|
+
});
|