@torrent-tv/proxy 2.9.105 → 2.9.107

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,12 @@
1
+ ## 2.9.107
2
+
3
+ - **Fix**: 2.9.106 could not produce a playback plan at all — `Failed to prepare playback plan: firstSegmentMs is not defined`. Moving the two host timings to be read when a plan is ANSWERED removed the two variables but left the object literal still naming them, on the path that builds a fresh plan. My own linter reports it in four seconds and I did not run it, which is the second time an undeclared name has reached a release; `npm publish` now runs it, so this class of error cannot leave the machine again. The test added with 2.9.106 did not catch it because it exercises the cached path only — the fresh-plan path needs a real probe.
4
+
5
+ ## 2.9.106
6
+
7
+ - **Fix**: The two figures the browser needs to say how long until playback now reach it for the file that needs them most. Both are medians of sessions already finished on this host, and the plan read them at the moment it was BUILT and then cached the result — so the very first file opened after a restart got `null` for both and kept answering `null` for the life of the process, however many sessions ran afterwards. Measured 2026-08-05: a fresh proxy answered `null`, then created the session in 6 ms and produced the first segment in 21 479 ms. They are now read when the plan is ANSWERED, so a cached plan reports what the host currently knows. Covered by tests.
8
+ - **New**: When the stats route has nothing to report it says which thing is missing — the torrent handle, the file index, or neither. A source answered `peers=0 file=n/a header=n/a` for minutes on 2026-08-05 while that very torrent was announcing to trackers with hundreds of seeders, and the line could not tell those cases apart. That line is what the viewer's loading screen shows, so it has to be answerable from the log.
9
+
1
10
  ## 2.9.105
2
11
 
3
12
  - **Fix**: A reader's claim on pieces is put back when WebTorrent drops it, which is what stopped a download dead for eleven minutes. A reader declares the window it needs as a selection and withdraws it when it ends; that claim turns out not to be durable — the library deletes a selection the moment every piece in it is present (`remove fully downloaded selection`). While the reader keeps moving this is invisible, because the next window is claimed at once. It is fatal when the reader STOPS: the encoder gets held back by the look-ahead cap, ffmpeg stops reading, the reader parks on a window that is fully downloaded, the selection disappears, and no code of ours can notice because the reader is parked inside a write. Measured 2026-08-05: the encoder was suspended at 22:44:51, the download hit zero at 22:45:05 and stayed there for eleven minutes with 150 peer connections open and the new diagnostic reading `0 selection(s) covering 0 piece(s), 0 being asked, 0 blocks in flight`; when the encoder was let go there was nothing ahead of it. Live reader windows are now re-asserted from the pool's own timer, using the set the piece store already keeps, and only where something is actually missing — re-claiming a satisfied window would only be deleted again on the next pass.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.105",
3
+ "version": "2.9.107",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -17,7 +17,8 @@
17
17
  "start": "node ./bin/cli.js",
18
18
  "dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
19
19
  "test": "node --test",
20
- "lint": "biome lint ."
20
+ "lint": "biome lint .",
21
+ "prepublishOnly": "npm run lint"
21
22
  },
22
23
  "dependencies": {
23
24
  "@fastify/cors": "^11.2.0",
@@ -1,69 +1,82 @@
1
- import { logger } from "../../../../utils/logger.js";
2
-
3
- /**
4
- * Return download statistics for a registered torrent source.
5
- *
6
- * Provides peer count, transfer speeds, and per-file download progress so
7
- * that the browser client can display meaningful feedback while the proxy is
8
- * pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
9
- *
10
- * GET /api/sources/:sourceKey/stats?fileIndex=N
11
- *
12
- * @param {import("fastify").FastifyRequest} req
13
- * @param {import("fastify").FastifyReply} reply
14
- * @param {{
15
- * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
16
- * torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
17
- * }} deps
18
- * @returns {Promise<void>}
19
- */
20
- export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
21
- const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
22
- if (!sourceKey) {
23
- return reply.code(400).send({ error: "sourceKey is required." });
24
- }
25
-
26
- const sourceRecord = sourceRegistry.get(sourceKey);
27
- if (!sourceRecord) {
28
- return reply.code(404).send({ error: "Source key was not found." });
29
- }
30
-
31
- let torrent;
32
- try {
33
- // getTorrent resolves immediately when the torrent is already loaded.
34
- torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
35
- } catch (error) {
36
- const message = error instanceof Error ? error.message : String(error);
37
- return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
38
- }
39
-
40
- const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
41
- const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
42
-
43
- // Optional: pin the resume window to a FIXED byte offset for the duration of
44
- // one buffering episode (see getFileStats JSDoc) instead of the live, moving
45
- // read position — otherwise "bytes needed" can jump up mid-poll as the window
46
- // slides forward with playback/encoding progress.
47
- const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
48
- const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
49
-
50
- // Awaited: with the torrent on its own thread this is a round trip, not a
51
- // local lookup. Without the await the reply was the pending promise itself,
52
- // which serialises to `{}` — the empty stats seen in the field 2026-08-02.
53
- const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
54
-
55
- // Diagnostic: surface the real swarm state per poll so a cold-start download
56
- // stall (0 peers / header not advancing → playback-plan blocks on the codec
57
- // probe → browser timeout) is visible in the proxy log.
58
- const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
59
- const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
60
- const header =
61
- stats.headerBytes != null
62
- ? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
63
- : "n/a";
64
- logger.info(
65
- `[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}`
66
- );
67
-
68
- return reply.send(stats);
69
- }
1
+ import { logger } from "../../../../utils/logger.js";
2
+
3
+ /**
4
+ * Return download statistics for a registered torrent source.
5
+ *
6
+ * Provides peer count, transfer speeds, and per-file download progress so
7
+ * that the browser client can display meaningful feedback while the proxy is
8
+ * pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
9
+ *
10
+ * GET /api/sources/:sourceKey/stats?fileIndex=N
11
+ *
12
+ * @param {import("fastify").FastifyRequest} req
13
+ * @param {import("fastify").FastifyReply} reply
14
+ * @param {{
15
+ * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
16
+ * torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
17
+ * }} deps
18
+ * @returns {Promise<void>}
19
+ */
20
+ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
21
+ const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
22
+ if (!sourceKey) {
23
+ return reply.code(400).send({ error: "sourceKey is required." });
24
+ }
25
+
26
+ const sourceRecord = sourceRegistry.get(sourceKey);
27
+ if (!sourceRecord) {
28
+ return reply.code(404).send({ error: "Source key was not found." });
29
+ }
30
+
31
+ let torrent;
32
+ try {
33
+ // getTorrent resolves immediately when the torrent is already loaded.
34
+ torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
35
+ } catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
38
+ }
39
+
40
+ const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
41
+ const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
42
+
43
+ // Optional: pin the resume window to a FIXED byte offset for the duration of
44
+ // one buffering episode (see getFileStats JSDoc) instead of the live, moving
45
+ // read position — otherwise "bytes needed" can jump up mid-poll as the window
46
+ // slides forward with playback/encoding progress.
47
+ const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
48
+ const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
49
+
50
+ // Awaited: with the torrent on its own thread this is a round trip, not a
51
+ // local lookup. Without the await the reply was the pending promise itself,
52
+ // which serialises to `{}` — the empty stats seen in the field 2026-08-02.
53
+ const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
54
+
55
+ // Diagnostic: surface the real swarm state per poll so a cold-start download
56
+ // stall (0 peers / header not advancing → playback-plan blocks on the codec
57
+ // probe → browser timeout) is visible in the proxy log.
58
+ const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
59
+ const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
60
+ const header =
61
+ stats.headerBytes != null
62
+ ? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
63
+ : "n/a";
64
+ // When the answer is empty, say WHICH thing is missing. Field 2026-08-05: a
65
+ // source reported `peers=0 file=n/a header=n/a` for minutes while that very
66
+ // torrent was announcing to trackers with hundreds of seeders — and the line
67
+ // above cannot tell apart "the torrent handle is not the live one", "the file
68
+ // index did not resolve" and "no file index was asked for". That is what the
69
+ // viewer's loading screen was showing at the time, so it has to be
70
+ // answerable from the log rather than by reasoning about it afterwards.
71
+ const emptyAnswer = stats.fileProgress == null || (stats.numPeers === 0 && torrent.done !== true);
72
+ const detail = emptyAnswer
73
+ ? ` | infoHash=${String(torrent.infoHash).slice(0, 8)} files=${torrent.files?.length ?? "?"}` +
74
+ ` askedFor=${fileIndex ?? "none"} resolved=${torrent.files?.[fileIndex ?? -1] ? "yes" : "no"}` +
75
+ ` wires=${torrent.wires?.length ?? "?"} done=${torrent.done === true}`
76
+ : "";
77
+ logger.info(
78
+ `[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}${detail}`
79
+ );
80
+
81
+ return reply.send(stats);
82
+ }
@@ -286,6 +286,31 @@ export function createPlaybackPlanner({
286
286
  */
287
287
  const mediaInfoCache = new Map();
288
288
 
289
+ /**
290
+ * Attach what this host currently measures itself taking to create a session
291
+ * and to produce a first segment.
292
+ *
293
+ * Read at RESPONSE time, deliberately. Both are medians of sessions that have
294
+ * already finished on this host, so at the moment a plan is BUILT the very
295
+ * first file opened after a restart has none and gets `null` — and the plan
296
+ * is then cached, so that file kept answering `null` for the life of the
297
+ * process however many sessions ran afterwards. Measured 2026-08-05: a fresh
298
+ * 2.9.103 answered `null` for both, then produced the session in 6 ms and the
299
+ * first segment in 21 479 ms. The figures existed; the plan could not carry
300
+ * them, and the browser's estimate fell back to its own guess in exactly the
301
+ * cold-start case the feature was built for.
302
+ *
303
+ * @param {PlaybackPlan} plan
304
+ * @returns {PlaybackPlan}
305
+ */
306
+ function withHostTimings(plan) {
307
+ return {
308
+ ...plan,
309
+ expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
310
+ expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null
311
+ };
312
+ }
313
+
289
314
  return {
290
315
  /**
291
316
  * Media info the planner already probed for this file, or `null`. Lets the
@@ -322,7 +347,7 @@ export function createPlaybackPlanner({
322
347
  const cacheKey = `${sourceKey}:${fileIndex}`;
323
348
  const cached = cache.get(cacheKey);
324
349
  if (cached) {
325
- return cached;
350
+ return withHostTimings(cached);
326
351
  }
327
352
  // Where the time before playback goes. `cold-start` already breaks down
328
353
  // everything from the transcode-session request onwards, but the plan
@@ -365,7 +390,7 @@ export function createPlaybackPlanner({
365
390
  subtitleTracks: []
366
391
  };
367
392
  cache.set(cacheKey, plan);
368
- return plan;
393
+ return withHostTimings(plan);
369
394
  }
370
395
 
371
396
  // Pre-fetch file edges (head + tail), then probe — retrying while the
@@ -402,8 +427,6 @@ export function createPlaybackPlanner({
402
427
  }
403
428
  const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
404
429
  const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
405
- const firstSegmentMs = expectedFirstSegmentMs?.() ?? null;
406
- const sessionCreateMs = expectedSessionCreateMs?.() ?? null;
407
430
  logger.info(
408
431
  `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
409
432
  `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
@@ -430,11 +453,11 @@ export function createPlaybackPlanner({
430
453
  // Full track inventory for the browser's audio/subtitle menus.
431
454
  audioTracks: audioTracks ?? [],
432
455
  subtitleTracks: subtitleTracks ?? [],
433
- // What this host has recently taken to make a session's first segment.
434
- // Null until one has finished since startup.
435
- expectedFirstSegmentMs: firstSegmentMs,
436
- // Second term of the browser's estimate; see research/playback-eta-2026-08-05.md.
437
- expectedSessionCreateMs: sessionCreateMs
456
+ // Both host timings are filled in by `withHostTimings` on the way out,
457
+ // never here: read at build time they would be frozen into the cached
458
+ // plan, which is the bug fixed in 2.9.106.
459
+ expectedFirstSegmentMs: null,
460
+ expectedSessionCreateMs: null
438
461
  };
439
462
  // Only cache a plan whose codecs were actually detected. An empty probe is
440
463
  // a "header not downloaded yet" signal, not a valid result — caching it
@@ -463,9 +486,9 @@ export function createPlaybackPlanner({
463
486
  timeoutMs: 60_000
464
487
  })
465
488
  .catch(() => {});
466
- return plan;
489
+ return withHostTimings(plan);
467
490
  }
468
- return { ...plan, pending: true };
491
+ return withHostTimings({ ...plan, pending: true });
469
492
  }
470
493
  };
471
494
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file The host's own timings must reach the browser for the file that needs
3
+ * them most.
4
+ *
5
+ * The plan carries two figures the browser cannot measure for itself: how long
6
+ * this host takes to create a session, and how long it takes to produce a first
7
+ * segment. Both are medians of sessions that have already finished here, so at
8
+ * the moment a plan is BUILT the very first file opened after a restart has
9
+ * neither — and the plan is cached, so that file kept answering `null` for the
10
+ * life of the process however many sessions ran afterwards. Measured
11
+ * 2026-08-05: a fresh proxy answered `null` for both, then created the session
12
+ * in 6 ms and produced the first segment in 21 479 ms.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { createPlaybackPlanner } from "../services/playback-planner.js";
18
+
19
+ /**
20
+ * A planner whose host timings can be changed between calls, over a source that
21
+ * needs no torrent: transcoding disabled short-circuits to a cached direct plan
22
+ * without probing anything.
23
+ */
24
+ function plannerWithTimings() {
25
+ const timings = { create: null, first: null };
26
+ const planner = createPlaybackPlanner({
27
+ ffmpegBin: "ffmpeg",
28
+ localBaseUrl: "http://127.0.0.1:9090/stream",
29
+ sourceRegistry: { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:0" }) },
30
+ torrentPool: { getTorrent: async () => ({ files: [{ name: "a.mkv", length: 10 }] }) },
31
+ transcodeAudioEnabled: false,
32
+ expectedSessionCreateMs: () => timings.create,
33
+ expectedFirstSegmentMs: () => timings.first
34
+ });
35
+ return { planner, timings };
36
+ }
37
+
38
+ test("a plan built before the host had measured anything still reports them later", async () => {
39
+ const { planner, timings } = plannerWithTimings();
40
+
41
+ const cold = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
42
+ assert.equal(cold.expectedSessionCreateMs, null, "nothing has finished yet, so there is nothing to report");
43
+ assert.equal(cold.expectedFirstSegmentMs, null);
44
+
45
+ // Sessions run; the host now knows what it costs.
46
+ timings.create = 6;
47
+ timings.first = 21_479;
48
+
49
+ const warm = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
50
+ assert.equal(
51
+ warm.expectedSessionCreateMs,
52
+ 6,
53
+ "the cached plan withheld the figure the browser needed"
54
+ );
55
+ assert.equal(warm.expectedFirstSegmentMs, 21_479);
56
+ });
57
+
58
+ test("the figures follow the host as they change", async () => {
59
+ const { planner, timings } = plannerWithTimings();
60
+ timings.create = 100;
61
+ timings.first = 800;
62
+ const first = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
63
+ assert.equal(first.expectedFirstSegmentMs, 800);
64
+
65
+ timings.first = 7_000; // a re-encode session — an order of magnitude slower
66
+ const later = await planner.getPlan({ sourceKey: "src", fileIndex: 0 });
67
+ assert.equal(later.expectedFirstSegmentMs, 7_000, "the plan reported a stale figure");
68
+ });