@torrent-tv/proxy 2.9.102 → 2.9.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 2.9.104
2
+
3
+ - **Fix**: An encoder run that stops because its input ran dry is no longer reported as a finished file. ffmpeg exits 0 both when it reaches the end of the source and when the source simply stops delivering, and over HTTP it cannot tell the two apart — so when a torrent's download died mid-session (field 2026-08-05), a run that had produced 188 segments of 624 logged `encode-run complete`, the player consumed what was already on disk and then froze for 60 s on the first segment nobody was making. The claim is now checked against the playlist that was published: a run that stopped short is a failure, which the session can restart, rather than a completed file.
4
+ - **New**: A download that stalls says so, and says which of the two possible reasons it is. The same session spent five minutes at **1 KB/s** with 186 peer connections open and trackers reporting ~300 seeders, on a torrent that was not finished, and produced no log line at all — the collapse had to be reconstructed afterwards from three unrelated counters. A torrent with an active reader that drops below 32 KB/s for ten seconds now reports how many pieces are selected and still missing, how many are marked critical, how many peers hold what we want, how many are choking us, how many are being asked and how many blocks are in flight. That separates "the swarm was never told what we need" from "it was told and will not deliver", which the previous evidence could not.
5
+
6
+ ## 2.9.103
7
+
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+
1
14
  ## 2.9.102
2
15
 
3
16
  - **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`).
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.102",
3
+ "version": "2.9.104",
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
  }
@@ -7,7 +7,7 @@
7
7
  * immediately when all registered consumers release them.
8
8
  */
9
9
 
10
- import { createReadStream } from "node:fs";
10
+ import { createReadStream, readdirSync } from "node:fs";
11
11
  import { access, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
12
12
  import { Readable } from "node:stream";
13
13
  import os from "node:os";
@@ -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
@@ -990,7 +997,6 @@ export class HlsSessionManager {
990
997
  const sessionId = randomUUID();
991
998
  const createEntryMs = Date.now();
992
999
  const sessionDir = createSessionDirPath(sessionId);
993
- await mkdir(sessionDir, { recursive: true });
994
1000
  const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
995
1001
  inputUrl.searchParams.set("sourceKey", sourceKey);
996
1002
  inputUrl.searchParams.set("fileIndex", String(fileIndex));
@@ -1176,6 +1182,14 @@ export class HlsSessionManager {
1176
1182
  const encodeWidth = encodeBudget?.width ?? normalizedTargetWidth;
1177
1183
  const encodeHeight = encodeBudget?.height ?? normalizedTargetHeight;
1178
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
+
1179
1193
  const session = {
1180
1194
  id: sessionId,
1181
1195
  sourceMapKey,
@@ -2380,6 +2394,29 @@ export class HlsSessionManager {
2380
2394
  return;
2381
2395
  }
2382
2396
  if (code === 0) {
2397
+ // ffmpeg exits 0 both when it reaches the end of the file and when its
2398
+ // input simply stops producing bytes — over HTTP the two look identical
2399
+ // to it. Field 2026-08-05: the torrent's download died, the read ended,
2400
+ // and a run that had made 188 segments of 624 reported itself complete;
2401
+ // the player then consumed what was on disk and froze on the first
2402
+ // segment nobody was making. So the claim is checked against the
2403
+ // playlist we published, and a run that stopped short is a FAILURE that
2404
+ // can be restarted, not a finished file.
2405
+ const producedThrough = this.#latestProducedSegment(session);
2406
+ const expectedLast = session.segmentCount > 0 ? session.segmentCount - 1 : null;
2407
+ if (expectedLast !== null && producedThrough !== null && producedThrough < expectedLast) {
2408
+ session.state = "failed";
2409
+ session.progress.state = "failed";
2410
+ session.progress.updatedAt = Date.now();
2411
+ session.lastError =
2412
+ `input ended after segment #${producedThrough} of ${expectedLast} — ` +
2413
+ "the source stopped delivering data";
2414
+ logger.error(
2415
+ `transcode ${session.id} ${session.runLabel ?? "run#?"} encode-run ended early: ` +
2416
+ `${session.lastError} "${session.fileName}"`
2417
+ );
2418
+ return;
2419
+ }
2383
2420
  session.state = "ready";
2384
2421
  session.progress.state = "ready";
2385
2422
  session.progress.updatedAt = Date.now();
@@ -2809,6 +2846,27 @@ export class HlsSessionManager {
2809
2846
  return sorted[Math.floor(sorted.length / 2)];
2810
2847
  }
2811
2848
 
2849
+ /**
2850
+ * The highest segment index this session has on disk, or null when it has
2851
+ * none. Used to tell "the file ended" from "the data ran out".
2852
+ *
2853
+ * @param {HlsSession} session
2854
+ * @returns {number | null}
2855
+ */
2856
+ #latestProducedSegment(session) {
2857
+ let highest = null;
2858
+ for (const name of readdirSync(session.dirPath, { withFileTypes: false })) {
2859
+ if (!this.segmentFormat.isSegmentFileName(name)) {
2860
+ continue;
2861
+ }
2862
+ const index = this.segmentFormat.segmentIndexFromName(name);
2863
+ if (index >= 0 && (highest === null || index > highest)) {
2864
+ highest = index;
2865
+ }
2866
+ }
2867
+ return highest;
2868
+ }
2869
+
2812
2870
  /**
2813
2871
  * How many times the viewer has moved since this session started.
2814
2872
  *
@@ -92,6 +92,73 @@ const UPLOAD_ADJUST_INTERVAL_MS = 5_000;
92
92
  * outright, for two unchoke cycles, without waiting for evidence of failure.
93
93
  */
94
94
  const UPLOAD_HURRY_MS = 25_000;
95
+ // A torrent somebody is reading, that is not finished, and is moving less than
96
+ // this, is not downloading. Well below the slowest real swarm seen in the field
97
+ // (470 KB/s two seconds after a cold add) and well above idle chatter.
98
+ const STALL_SPEED_BYTES = 32 * 1024;
99
+ // How long it must stay there before saying so, and how often to repeat.
100
+ const STALL_REPORT_AFTER_MS = 10_000;
101
+ const STALL_REPORT_INTERVAL_MS = 30_000;
102
+
103
+ /**
104
+ * What the swarm has been asked for, and what it is doing about it.
105
+ *
106
+ * Answers the question a stalled download cannot answer for itself: were the
107
+ * peers never told what we want, or told and not delivering? Reaches into
108
+ * WebTorrent's own bookkeeping because none of it is exposed — `_selections`
109
+ * is what the picker walks, `wire.requests` is what is actually outstanding.
110
+ *
111
+ * @param {import("webtorrent").Torrent} torrent
112
+ * @returns {string}
113
+ */
114
+ function describeSwarmDemand(torrent) {
115
+ const items = Array.isArray(torrent?._selections?._items) ? torrent._selections._items : [];
116
+ let selectedPieces = 0;
117
+ let missingSelected = 0;
118
+ for (const item of items) {
119
+ const from = Number(item?.from);
120
+ const to = Number(item?.to);
121
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
122
+ continue;
123
+ }
124
+ selectedPieces += to - from + 1;
125
+ for (let index = from; index <= to; index += 1) {
126
+ if (!torrent.bitfield?.get(index)) {
127
+ missingSelected += 1;
128
+ }
129
+ }
130
+ }
131
+
132
+ const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
133
+ let inFlight = 0;
134
+ let asking = 0;
135
+ let choking = 0;
136
+ let interested = 0;
137
+ for (const wire of wires) {
138
+ const requests = Array.isArray(wire?.requests) ? wire.requests.length : 0;
139
+ inFlight += requests;
140
+ if (requests > 0) {
141
+ asking += 1;
142
+ }
143
+ if (wire?.peerChoking === true) {
144
+ choking += 1;
145
+ }
146
+ if (wire?.amInterested === true) {
147
+ interested += 1;
148
+ }
149
+ }
150
+
151
+ const critical = Array.isArray(torrent?._critical)
152
+ ? torrent._critical.reduce((count, flag) => (flag ? count + 1 : count), 0)
153
+ : 0;
154
+
155
+ return (
156
+ `${items.length} selection(s) covering ${selectedPieces} piece(s), ` +
157
+ `${missingSelected} of them missing, ${critical} marked critical; ` +
158
+ `${wires.length} peers, ${interested} we want data from, ${choking} choking us, ` +
159
+ `${asking} being asked, ${inFlight} blocks in flight`
160
+ );
161
+ }
95
162
 
96
163
  /**
97
164
  * Decide the client-wide upload limit (bytes/sec) from the torrents that
@@ -383,6 +450,11 @@ export class TorrentPool {
383
450
  */
384
451
  #readPositionByTorrent = new Map();
385
452
 
453
+ /** When each torrent's download first fell below the stall threshold. */
454
+ #stallSince = new Map();
455
+ /** When each torrent's stall was last reported, so it is not repeated hotly. */
456
+ #stallReportedAt = new Map();
457
+
386
458
  /**
387
459
  * Edge prefetches currently running, keyed by infoHash and file index, so two
388
460
  * callers asking at the same time share one.
@@ -507,6 +579,53 @@ export class TorrentPool {
507
579
  this.#adjustUploadLimit();
508
580
  }
509
581
 
582
+ /**
583
+ * Say when a torrent that somebody is reading has stopped downloading.
584
+ *
585
+ * Field 2026-08-05: a session died 32 minutes in because the download fell to
586
+ * **1 KB/s for five minutes** with 186 peer connections open and trackers
587
+ * reporting ~300 seeders, on a torrent that was not finished. ffmpeg then ran
588
+ * out of input and reported itself complete at segment 188 of 624. Not one
589
+ * line of the log said anything was wrong — the collapse had to be recovered
590
+ * afterwards by hand from three unrelated counters.
591
+ *
592
+ * So the stall reports itself, and it reports the two things that tell the
593
+ * candidates apart: whether the swarm was ASKED for anything (pieces selected
594
+ * and still missing, blocks in flight) or was asked and did not answer (peers
595
+ * holding what we want, how many are choking us).
596
+ *
597
+ * @returns {void}
598
+ */
599
+ #reportStalledDownloads() {
600
+ const now = Date.now();
601
+ for (const torrent of this.torrents.values()) {
602
+ const usage = this.fileUsageByTorrent.get(torrent);
603
+ if (!usage || usage.size === 0 || torrent?.done === true) {
604
+ continue;
605
+ }
606
+ const speed = typeof torrent.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
607
+ if (speed >= STALL_SPEED_BYTES) {
608
+ this.#stallSince.delete(torrent);
609
+ continue;
610
+ }
611
+ const since = this.#stallSince.get(torrent) ?? now;
612
+ this.#stallSince.set(torrent, since);
613
+ if (now - since < STALL_REPORT_AFTER_MS) {
614
+ continue;
615
+ }
616
+ const lastReport = this.#stallReportedAt.get(torrent) ?? 0;
617
+ if (now - lastReport < STALL_REPORT_INTERVAL_MS) {
618
+ continue;
619
+ }
620
+ this.#stallReportedAt.set(torrent, now);
621
+ logger.warn(
622
+ `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] download stalled at ` +
623
+ `${Math.round(speed / 1024)}KB/s for ${Math.round((now - since) / 1000)}s — ` +
624
+ describeSwarmDemand(torrent)
625
+ );
626
+ }
627
+ }
628
+
510
629
  #adjustUploadLimit() {
511
630
  if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
512
631
  return;
@@ -516,6 +635,7 @@ export class TorrentPool {
516
635
  this.fileUsageByTorrent,
517
636
  Date.now()
518
637
  );
638
+ this.#reportStalledDownloads();
519
639
  const { bytesPerSec, reason } = decideUploadLimit(active);
520
640
  if (bytesPerSec === this.#uploadLimit) {
521
641
  return;
@@ -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