@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.49.0",
3
+ "version": "2.51.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- return times.length > 0 ? times : null;
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
- times = await reader.read(readRange, fileSize);
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 SEEK_LANDING_OFFSET_SEC;
1261
+ return wanted;
1203
1262
  }
1204
- return Math.min(SEEK_LANDING_OFFSET_SEC, (next - keyframe) / 2);
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 container keyframe index for "${logName}"; ` +
1786
- `falling back to a uniform grid segment boundaries will not match the media`
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.#segmentStartTime(session, head);
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 boundary table.
3868
- const startSeconds = this.#segmentStartTime(session, safeIndex);
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.#segmentStartTime(session, head);
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.#segmentStartTime(session, head);
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
- const asked = this.#segmentStartTime(session, index);
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
- : "the container's keyframe index disagrees with the file; using the file")
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. The boundary now holds the corrected
5387
- // time, so starting the run at this index starts it at that time.
5388
- void this.#startEncodeRun(member, index).catch(() => {});
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
- const boundaries = session.segmentBoundaries;
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
- const runStartSeconds = this.#segmentStartTime(session, session.encodeStartIndex ?? 0);
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";
@@ -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
+ });