@torrent-tv/proxy 2.9.101 → 2.9.103

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,15 @@
1
+ ## 2.9.103
2
+
3
+ - **Fix**: Playback worked in neither 2.9.101 nor 2.9.102. Both cold-start estimates keep a window of recent samples, and the constant naming that window was used twice and declared nowhere. The session-create one runs on every new session, so `POST /api/transcode-sessions` answered 500 to every viewer and the browser then reported the first segment missing. Field session 2026-08-05: the plan succeeded in 5858 ms, the session request failed 47 ms later, the data channel closed 16 ms after that.
4
+ - **Fix**: The fallback read path threw the same way. `createReadStream` passed a `windowBytes` its own signature never accepted — a reference to nothing, which in a module is an error, not an undefined. It is the path taken for a source with no shared piece pool, so it had never run on a host where it would have been noticed.
5
+ - **Fix**: A failed session no longer leaves its directory behind. It was created before the probe and the keyframe index, both of which can fail, and nothing tracks or sweeps a directory whose session was never registered.
6
+ - **New**: The transcode-session route says why it failed, on the proxy's own log and with the stack. It caught, answered 500 and stayed silent, so the log carried only the data-channel layer's bare `→ 500`: the cause of the defect above had to be recovered by replaying the request against the live proxy.
7
+ - **Chore**: The proxy has a linter. It had none, and the rule for an undeclared name catches this whole class outright — it found the second occurrence above on its first run. Biome, `npm run lint`, limited to the correctness rules that describe real faults rather than style.
8
+
9
+ ## 2.9.102
10
+
11
+ - **New**: The playback plan also reports what this host takes to CREATE a session — median of the last eight, 116-843 ms depending on whether the keyframe index is already in hand. It is the second term of the browser's end-to-end estimate, which is being rebuilt as a sum over the stages that have not happened yet rather than a choice between figures that each describe only one of them (`research/playback-eta-2026-08-05.md`).
12
+
1
13
  ## 2.9.101
2
14
 
3
15
  - **New**: The playback plan reports what this host takes to produce a session's first segment — the median of its last eight, measured from session-create to a servable segment (782-1518 ms on the field host). The browser needs it for the gap between "the file is downloaded" and "a segment exists", where until now it assumed the pipeline merely keeps up with realtime and therefore showed 15 s where 3.8 s were left. It is per-host, so a weak box and a fast one each answer for themselves.
package/biome.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
3
+ "files": {
4
+ "includes": ["**/*.js", "!**/node_modules/**"]
5
+ },
6
+ "formatter": { "enabled": false },
7
+ "assist": { "enabled": false },
8
+ "linter": {
9
+ "enabled": true,
10
+ "rules": {
11
+ "preset": "none",
12
+ "correctness": {
13
+ "noUndeclaredVariables": "error",
14
+ "noUnusedImports": "error",
15
+ "noUnusedVariables": "error",
16
+ "noInvalidUseBeforeDeclaration": "error",
17
+ "noPrivateImports": "error"
18
+ }
19
+ }
20
+ }
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.101",
3
+ "version": "2.9.103",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -16,7 +16,8 @@
16
16
  "major": "npm whoami && npm version major && npm publish && git push --follow-tags",
17
17
  "start": "node ./bin/cli.js",
18
18
  "dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
19
- "test": "node --test"
19
+ "test": "node --test",
20
+ "lint": "biome lint ."
20
21
  },
21
22
  "dependencies": {
22
23
  "@fastify/cors": "^11.2.0",
@@ -34,5 +35,8 @@
34
35
  "webtorrent": "2.8.5",
35
36
  "werift": "^0.24.2",
36
37
  "ws": "^8.18.2"
38
+ },
39
+ "devDependencies": {
40
+ "@biomejs/biome": "^2.5.7"
37
41
  }
38
42
  }
@@ -9,6 +9,8 @@
9
9
  * @returns {Promise<void>}
10
10
  */
11
11
 
12
+ import { logger } from "../../../utils/logger.js";
13
+
12
14
  /**
13
15
  * Extract a plain object from the request body, guarding against
14
16
  * non-object payloads (arrays, primitives, null).
@@ -77,6 +79,17 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
77
79
  return reply.code(409).send({ error: error.message });
78
80
  }
79
81
  const message = error instanceof Error ? error.message : String(error);
82
+ // Say why on the proxy's own log, not only in the answer. This route
83
+ // answered 500 for every viewer of proxy 2.9.101-2.9.102 (an undeclared
84
+ // constant) and the addon log carried nothing but the data-channel layer's
85
+ // bare "→ 500": the cause had to be recovered by replaying the request
86
+ // against the live proxy. The stack is worth the two lines it costs — a
87
+ // programming error here is invisible to the viewer, who only sees that
88
+ // nothing plays.
89
+ logger.error(
90
+ `transcode-sessions: ${sourceKey}:${fileIndex} failed to prepare: ${message}\n` +
91
+ `${error instanceof Error ? (error.stack ?? "") : ""}`
92
+ );
80
93
  return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
81
94
  }
82
95
  }
package/server.js CHANGED
@@ -165,7 +165,8 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
165
165
  sourceRegistry,
166
166
  torrentPool,
167
167
  warmKeyframeIndex: (params) => hlsSessionManager.warmKeyframeIndex(params),
168
- expectedFirstSegmentMs: () => hlsSessionManager.expectedFirstSegmentMs()
168
+ expectedFirstSegmentMs: () => hlsSessionManager.expectedFirstSegmentMs(),
169
+ expectedSessionCreateMs: () => hlsSessionManager.expectedSessionCreateMs()
169
170
  });
170
171
 
171
172
  app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
@@ -181,6 +181,13 @@ const LINK_LOW_BUFFER_SEC = 10;
181
181
  // Observed produced bitrate: average over this many recently completed
182
182
  // segments (the newest file on disk may still be written and is excluded).
183
183
  const LINK_OBSERVED_SEGMENTS = 5;
184
+ // How many recent runs the two cold-start estimates keep. Both the
185
+ // session-create time and the first-segment time are reported to the browser as
186
+ // the median of this many samples, so it has to be long enough that one slow run
187
+ // does not move the figure and short enough that the estimate still follows the
188
+ // host: a proxy whose swarm has warmed up, or which has just picked up a second
189
+ // viewer, should stop quoting the numbers from ten minutes ago.
190
+ const FIRST_SEGMENT_SAMPLES = 20;
184
191
  const MICROSECONDS_PER_SECOND = 1_000_000;
185
192
  const PROGRESS_LOG_INTERVAL_MS = 5_000;
186
193
  // Read segment files in large blocks so the body is delivered to the data
@@ -808,6 +815,16 @@ export class HlsSessionManager {
808
815
  */
809
816
  #firstSegmentLatencies = [];
810
817
 
818
+ /**
819
+ * Recent times to create a session, in ms — the second term of the browser's
820
+ * estimate. Measured for the same reason as the first: it is 116-843 ms
821
+ * depending on whether the keyframe index is already in hand, and guessing it
822
+ * was one of the ways the shown figure stopped describing the whole wait.
823
+ *
824
+ * @type {number[]}
825
+ */
826
+ #sessionCreateLatencies = [];
827
+
811
828
  /**
812
829
  * @param {HlsSessionManagerOptions} options
813
830
  */
@@ -980,7 +997,6 @@ export class HlsSessionManager {
980
997
  const sessionId = randomUUID();
981
998
  const createEntryMs = Date.now();
982
999
  const sessionDir = createSessionDirPath(sessionId);
983
- await mkdir(sessionDir, { recursive: true });
984
1000
  const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
985
1001
  inputUrl.searchParams.set("sourceKey", sourceKey);
986
1002
  inputUrl.searchParams.set("fileIndex", String(fileIndex));
@@ -1121,6 +1137,7 @@ export class HlsSessionManager {
1121
1137
  );
1122
1138
  });
1123
1139
  }
1140
+ this.#rememberSessionCreateLatency(Date.now() - createEntryMs);
1124
1141
  logger.info(
1125
1142
  `cold-start ${sessionId.slice(0, 8)}: media-info=${mediaInfoMs}ms (${mediaInfoSource}) ` +
1126
1143
  `keyframes=${keyframeMs === -1 ? "skipped" : keyframeMs === -2 ? "background" : `${keyframeMs}ms`} ` +
@@ -1165,6 +1182,14 @@ export class HlsSessionManager {
1165
1182
  const encodeWidth = encodeBudget?.width ?? normalizedTargetWidth;
1166
1183
  const encodeHeight = encodeBudget?.height ?? normalizedTargetHeight;
1167
1184
 
1185
+ // Only now, when nothing above can still throw. Everything from the probe
1186
+ // to the keyframe index used to run with the directory already made, so a
1187
+ // failure between the two left it behind: nothing tracks a directory whose
1188
+ // session was never registered, and no sweep looks for one. Proxy
1189
+ // 2.9.101-2.9.102 failed here on every single request and the leftovers
1190
+ // were the only trace of it on disk.
1191
+ await mkdir(sessionDir, { recursive: true });
1192
+
1168
1193
  const session = {
1169
1194
  id: sessionId,
1170
1195
  sourceMapKey,
@@ -2750,6 +2775,30 @@ export class HlsSessionManager {
2750
2775
  * @param {number} latencyMs
2751
2776
  * @returns {void}
2752
2777
  */
2778
+ #rememberSessionCreateLatency(latencyMs) {
2779
+ if (!Number.isFinite(latencyMs) || latencyMs <= 0) {
2780
+ return;
2781
+ }
2782
+ this.#sessionCreateLatencies.push(latencyMs);
2783
+ if (this.#sessionCreateLatencies.length > FIRST_SEGMENT_SAMPLES) {
2784
+ this.#sessionCreateLatencies.shift();
2785
+ }
2786
+ }
2787
+
2788
+ /**
2789
+ * What this host typically takes to create a session, in ms — the median of
2790
+ * recent ones, or null before any has finished.
2791
+ *
2792
+ * @returns {number | null}
2793
+ */
2794
+ expectedSessionCreateMs() {
2795
+ if (this.#sessionCreateLatencies.length === 0) {
2796
+ return null;
2797
+ }
2798
+ const sorted = [...this.#sessionCreateLatencies].sort((left, right) => left - right);
2799
+ return sorted[Math.floor(sorted.length / 2)];
2800
+ }
2801
+
2753
2802
  #rememberFirstSegmentLatency(latencyMs) {
2754
2803
  if (!Number.isFinite(latencyMs) || latencyMs <= 0) {
2755
2804
  return;
@@ -269,6 +269,7 @@ export function createPlaybackPlanner({
269
269
  // downloaded" and "a segment exists": until now it assumed the pipeline
270
270
  // merely keeps up with realtime, and showed 15 s where 3.8 s were left.
271
271
  expectedFirstSegmentMs,
272
+ expectedSessionCreateMs,
272
273
  // Optional. Called once the file's edges are downloaded, so the keyframe
273
274
  // index — which reads the same tail of the file — is fetched alongside the
274
275
  // codec probe instead of after it. Late-bound to the HLS session manager,
@@ -402,6 +403,7 @@ export function createPlaybackPlanner({
402
403
  const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
403
404
  const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
404
405
  const firstSegmentMs = expectedFirstSegmentMs?.() ?? null;
406
+ const sessionCreateMs = expectedSessionCreateMs?.() ?? null;
405
407
  logger.info(
406
408
  `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
407
409
  `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
@@ -430,7 +432,9 @@ export function createPlaybackPlanner({
430
432
  subtitleTracks: subtitleTracks ?? [],
431
433
  // What this host has recently taken to make a session's first segment.
432
434
  // Null until one has finished since startup.
433
- expectedFirstSegmentMs: firstSegmentMs
435
+ expectedFirstSegmentMs: firstSegmentMs,
436
+ // Second term of the browser's estimate; see research/playback-eta-2026-08-05.md.
437
+ expectedSessionCreateMs: sessionCreateMs
434
438
  };
435
439
  // Only cache a plan whose codecs were actually detected. An empty probe is
436
440
  // a "header not downloaded yet" signal, not a valid result — caching it
@@ -231,7 +231,7 @@ export class TorrentWorkerClient {
231
231
  * @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null, windowBytes?: number }} params
232
232
  * @returns {ReadableStream<Uint8Array>}
233
233
  */
234
- createReadStream({ sourceKey, fileIndex, start = null, end = null }) {
234
+ createReadStream({ sourceKey, fileIndex, start = null, end = null, windowBytes }) {
235
235
  // Same id sequence as commands — see `nextId` in `channel.js`.
236
236
  const readId = this.#caller.nextId();
237
237
  const receive = createReceiveStream({
@@ -407,7 +407,8 @@ export class TorrentWorkerClient {
407
407
  sourceKey,
408
408
  fileIndex: file.index,
409
409
  start: options.start ?? null,
410
- end: options.end ?? null
410
+ end: options.end ?? null,
411
+ windowBytes: options.windowBytes
411
412
  })
412
413
  );
413
414
  },
@@ -15,7 +15,6 @@
15
15
  import test from "node:test";
16
16
  import assert from "node:assert/strict";
17
17
  import { Worker } from "node:worker_threads";
18
- import { fileURLToPath } from "node:url";
19
18
  import {
20
19
  RTCPeerConnection,
21
20
  RTCSessionDescription