@torrent-tv/proxy 2.9.96 → 2.9.98

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,13 @@
1
+ ## 2.9.98
2
+
3
+ - **Fix**: The upload is no longer raised at moments when nobody wants a byte. A torrent with no reader was counted as starving whenever its download read low — which it always does while the encoder is held back for running ahead of the viewer. Measured: four cycles of 512 KB/s and back in three minutes, each reported as `earn unchoke … down=0KB/s`. Starvation now requires somebody to be waiting.
4
+ - **Fix**: A session start no longer looks like a burst of seeks. The codec probe and the keyframe index read through the same route as the encoder and visit the first bytes and the last ones, which from byte offsets alone is indistinguishable from a viewer dragging the slider — two spurious "the viewer moved" per start, and more on every encoder restart. The encoder's input URL now says that it is the read that follows the viewer, and only that read counts.
5
+ - **Chore**: The per-run ffmpeg log line abbreviates the list of cut times to its count and its two ends. There is one cut per segment — 830 on a two-hour film, about 7 KB of log per run — and the list is only ever consulted for whether cutting was explicit, where it starts and how far it reaches.
6
+
7
+ ## 2.9.97
8
+
9
+ - **Fix**: The generous upload of 2.9.96 did not actually reach the moment it was written for. Only torrents with a registered reader were shown to the upload policy, and the first thing done with a new torrent — fetching the file's head and tail for the codec probe — reads through `createReadStream` without registering one. So for the whole of that wait, 8.36 s of the 11.46 s before playback in the measured session, the torrent looked unused and the upload stayed at the near-silent idle floor, during the exact seconds peers decide whether to serve us. A torrent in a hurry now counts whether or not anything is reading it. The selection is a named function of its own so it can be tested without a live swarm — the fault was in which torrents were considered, not in what was decided about them.
10
+
1
11
  ## 2.9.96
2
12
 
3
13
  - **New**: The proxy uploads generously at the two moments a viewer is provably waiting — when a torrent is added, and when the viewer seeks — for 25 s, which is two of BitTorrent's unchoke cycles. Peers serve those who serve them: each re-ranks its takers about every 10 s and opens a few slots to whoever uploaded most, plus one at random, so uploading a token 8-50 KB/s means being picked at random, one slot per cycle. Measured on a session where 96 peers were already connected within 2 s: 64 KB/s after 2 s, 1.6 MB/s after 4 s, 4.8 MB/s after 8 s — and the 16 MB the codec probe needs took **8.36 s of the 11.46 s** before playback could start. The existing reciprocity boost could not help, because it waits for the download to be all but dead (below 200 KB/s) with peers visibly choking us, and a ramp is neither: in that same session it first moved the limit 13.3 s after the torrent was added and reached the generous rate at 43.7 s, both after the wait they were meant to shorten. Seeding policy is otherwise unchanged — near-silence when nothing is being watched, a token upload while reading.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.96",
3
+ "version": "2.9.98",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -144,8 +144,14 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool
144
144
  // byte offset) downloads first instead of waiting behind the sequential
145
145
  // backlog — this is what caused ~15-18 s stalls when seeking into an
146
146
  // undownloaded region.
147
+ // Only the encoder's own input read tracks where the viewer is. Everything
148
+ // else that comes through here — the codec probe, the keyframe index, a
149
+ // subtitle fetch — reads the file's edges, and treating those as a viewer
150
+ // position made every session start and every encoder restart look like a
151
+ // burst of seeks (measured: two spurious "the viewer moved" per start).
147
152
  torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0, undefined, {
148
- wholeFileRead: range === null
153
+ wholeFileRead: range === null,
154
+ isPlaybackRead: req.query.reader === "playback"
149
155
  });
150
156
 
151
157
  const start = range ? range.start : 0;
@@ -672,6 +672,33 @@ function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, key
672
672
  * @returns {number[] | null} Times relative to the run start, or null when the
673
673
  * boundaries cannot serve (missing, or the index is outside them).
674
674
  */
675
+ /**
676
+ * The ffmpeg command as one readable line.
677
+ *
678
+ * Everything is shown as passed except the list of cut times, which is one
679
+ * value per segment — 830 of them on a two-hour film, about 7 KB of log for a
680
+ * single run, repeated on every restart. The count and the two ends say
681
+ * everything the list is ever consulted for: whether cutting was explicit at
682
+ * all, how far it reaches, and where it starts.
683
+ *
684
+ * @param {string[]} args
685
+ * @returns {string}
686
+ */
687
+ export function describeFfmpegArgs(args) {
688
+ const parts = [];
689
+ for (let index = 0; index < args.length; index += 1) {
690
+ const value = args[index];
691
+ if (value === "-segment_times" && typeof args[index + 1] === "string") {
692
+ const times = args[index + 1].split(",");
693
+ parts.push(value, `<${times.length} cuts ${times[0]}..${times[times.length - 1]}>`);
694
+ index += 1;
695
+ continue;
696
+ }
697
+ parts.push(value);
698
+ }
699
+ return parts.join(" ");
700
+ }
701
+
675
702
  export function segmentCutTimesFrom(boundaries, startIndex) {
676
703
  if (!Array.isArray(boundaries) || boundaries.length < 2) {
677
704
  return null;
@@ -999,6 +1026,12 @@ export class HlsSessionManager {
999
1026
  const readWindowBytes = await this.#readWindowBytesFor(sourceKey, fileIndex, durationSeconds);
1000
1027
  if (readWindowBytes > 0) {
1001
1028
  inputUrl.searchParams.set("windowBytes", String(readWindowBytes));
1029
+ // This read, and only this read, follows the viewer. The codec probe and
1030
+ // the keyframe index also go through `/stream`, and they jump between the
1031
+ // first bytes and the last ones — which is indistinguishable, from byte
1032
+ // offsets alone, from someone dragging the slider. Saying so here is
1033
+ // cheaper and more truthful than guessing from the offsets.
1034
+ inputUrl.searchParams.set("reader", "playback");
1002
1035
  }
1003
1036
  if (!hasDuration) {
1004
1037
  logger.warn(
@@ -2132,7 +2165,7 @@ export class HlsSessionManager {
2132
2165
  // were verified to handle a copied AC-3 track on this very host, so the
2133
2166
  // arguments that run actually received are the missing evidence. One line
2134
2167
  // per run, and a run happens at most every few seconds.
2135
- logger.info(`transcode ${session.id} ffmpeg ${args.join(" ")}`);
2168
+ logger.info(`transcode ${session.id} ffmpeg ${describeFfmpegArgs(args)}`);
2136
2169
 
2137
2170
  const ffmpeg = spawn(this.ffmpegBin, args, {
2138
2171
  cwd: session.dirPath,
@@ -115,6 +115,38 @@ const UPLOAD_HURRY_MS = 25_000;
115
115
  * @param {{ floor?: number, idleFloor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number, now?: number }} [opts]
116
116
  * @returns {{ bytesPerSec: number, reason: string }}
117
117
  */
118
+ /**
119
+ * The torrents the upload policy is allowed to see.
120
+ *
121
+ * A torrent with a reader qualifies for the obvious reason. A torrent in a
122
+ * HURRY qualifies even without one, and that case is the important one: the
123
+ * first thing done with a new torrent is fetching the file's head and tail for
124
+ * the codec probe, and that read goes straight to `createReadStream` without
125
+ * registering a reader. Judged by readers alone the torrent looks unused for
126
+ * the whole of that wait — 8.36 s of the 11.46 s before playback in the session
127
+ * measured 2026-08-04 — so the upload stayed at the near-silent idle floor
128
+ * during the exact seconds peers were deciding whether to serve us.
129
+ *
130
+ * @param {Iterable<{ hurryUntil?: number }>} torrents
131
+ * @param {Map<object, { size: number }>} usageByTorrent - fileIndex sets, keyed by torrent.
132
+ * @param {number} now
133
+ * @returns {object[]}
134
+ */
135
+ export function torrentsForUploadPolicy(torrents, usageByTorrent, now) {
136
+ const chosen = [];
137
+ for (const torrent of torrents) {
138
+ const usage = usageByTorrent?.get?.(torrent);
139
+ const hasReader = Boolean(usage && usage.size > 0);
140
+ if (hasReader || (torrent?.hurryUntil ?? 0) > now) {
141
+ // Recorded so the policy can tell "nothing is arriving and somebody is
142
+ // waiting" from "nothing is arriving because nobody asked".
143
+ torrent.hasActiveReader = hasReader;
144
+ chosen.push(torrent);
145
+ }
146
+ }
147
+ return chosen;
148
+ }
149
+
118
150
  export function decideUploadLimit(activeTorrents, opts = {}) {
119
151
  const floor = opts.floor ?? UPLOAD_FLOOR_BYTES;
120
152
  const idleFloor = opts.idleFloor ?? UPLOAD_IDLE_FLOOR_BYTES;
@@ -148,7 +180,13 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
148
180
  // iterate the piece array and throw on webtorrent 3.x when a piece is null
149
181
  // (deselected / mid-verify), which would crash this timer every cycle.
150
182
  const notDone = torrent?.done !== true;
151
- const starving = notDone && downloadSpeed < starvingSpeed;
183
+ // A torrent nobody is reading is not starving, however still its download
184
+ // looks. The encoder is held back once it is far enough ahead of the
185
+ // viewer, and while it is held nothing is requested — measured
186
+ // 2026-08-04: four cycles of 512 -> 50 KB/s in three minutes, each
187
+ // reported as `earn unchoke ... down=0KB/s`, all of them raising the
188
+ // upload at moments when no byte was wanted by anyone.
189
+ const starving = notDone && torrent?.hasActiveReader !== false && downloadSpeed < starvingSpeed;
152
190
  if (starving && chokedInterested >= chokedThreshold) {
153
191
  const name = typeof torrent?.name === "string" ? torrent.name : "?";
154
192
  return {
@@ -465,10 +503,11 @@ export class TorrentPool {
465
503
  if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
466
504
  return;
467
505
  }
468
- const active = [...this.torrents.values()].filter((torrent) => {
469
- const usage = this.fileUsageByTorrent.get(torrent);
470
- return usage && usage.size > 0;
471
- });
506
+ const active = torrentsForUploadPolicy(
507
+ this.torrents.values(),
508
+ this.fileUsageByTorrent,
509
+ Date.now()
510
+ );
472
511
  const { bytesPerSec, reason } = decideUploadLimit(active);
473
512
  if (bytesPerSec === this.#uploadLimit) {
474
513
  return;
@@ -1096,9 +1135,11 @@ export class TorrentPool {
1096
1135
  * @param {number} fileIndex
1097
1136
  * @param {number} byteStart - Start offset within the file.
1098
1137
  * @param {number} [windowBytes] - Unused; kept so callers need not change.
1099
- * @param {{ wholeFileRead?: boolean }} [options] - `wholeFileRead` marks a
1100
- * request that carried no byte range, i.e. one that merely opens the file at
1101
- * 0 rather than asking to read from there. See the guard below.
1138
+ * @param {{ wholeFileRead?: boolean, isPlaybackRead?: boolean }} [options] -
1139
+ * `wholeFileRead` marks a request that carried no byte range, i.e. one that
1140
+ * merely opens the file at 0 rather than asking to read from there.
1141
+ * `isPlaybackRead` marks the encoder's input read, the only one that
1142
+ * follows the viewer. See the guards below.
1102
1143
  * @returns {void}
1103
1144
  */
1104
1145
  prioritizeByteRange(
@@ -1153,10 +1194,15 @@ export class TorrentPool {
1153
1194
  const isJump =
1154
1195
  previousStart === undefined || Math.abs(safeStart - previousStart) > PRIORITY_WINDOW_BYTES;
1155
1196
  if (isJump) {
1156
- // A jump is a seek. Whatever the swarm was giving us was for somewhere
1157
- // else, and the pieces at the new position have to be earned from peers
1158
- // that are choking us — the same standing start as a fresh torrent.
1159
- this.#markHurry(torrent, "the viewer moved");
1197
+ // A jump in the read that follows the viewer is a seek. Whatever the
1198
+ // swarm was giving us was for somewhere else, and the pieces at the new
1199
+ // position have to be earned from peers that are choking us — the same
1200
+ // standing start as a fresh torrent. A jump in any OTHER read is the
1201
+ // codec probe or the keyframe index visiting the ends of the file, and
1202
+ // nobody is waiting on those the way a viewer waits on a seek.
1203
+ if (options.isPlaybackRead) {
1204
+ this.#markHurry(torrent, "the viewer moved");
1205
+ }
1160
1206
  const percent = ((safeStart / fileLength) * 100).toFixed(1);
1161
1207
  logger.info(
1162
1208
  `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] read position -> ` +
@@ -17,7 +17,7 @@
17
17
 
18
18
  import test from "node:test";
19
19
  import assert from "node:assert/strict";
20
- import { decideUploadLimit } from "../services/torrent-pool.js";
20
+ import { decideUploadLimit, torrentsForUploadPolicy } from "../services/torrent-pool.js";
21
21
 
22
22
  const NOW = 1_000_000;
23
23
  const healthy = (extra = {}) => ({
@@ -64,3 +64,58 @@ test("the reciprocity boost still works when no hurry is on", () => {
64
64
  assert.equal(decision.bytesPerSec, 512 * 1024);
65
65
  assert.match(decision.reason, /earn unchoke/);
66
66
  });
67
+
68
+ test("a torrent with no reader still reaches the policy while it is in a hurry", () => {
69
+ // The moment that matters: a torrent has just been added and its head and
70
+ // tail are being fetched for the codec probe. That read goes straight to
71
+ // `createReadStream`, so no reader is registered — and the selection only
72
+ // ever kept torrents that had one, which is where the gap was.
73
+ const hurrying = { name: "film.mkv", hurryUntil: NOW + 20_000 };
74
+ const idle = { name: "other.mkv" };
75
+ const usage = new Map();
76
+
77
+ assert.deepEqual(
78
+ torrentsForUploadPolicy([hurrying, idle], usage, NOW),
79
+ [hurrying],
80
+ "a torrent with no reader yet was ignored"
81
+ );
82
+
83
+ assert.deepEqual(
84
+ torrentsForUploadPolicy([{ ...hurrying, hurryUntil: NOW - 1 }, idle], usage, NOW),
85
+ [],
86
+ "and stops counting once the rush is over"
87
+ );
88
+
89
+ const read = { name: "watched.mkv" };
90
+ usage.set(read, new Set([0]));
91
+ assert.deepEqual(
92
+ torrentsForUploadPolicy([read], usage, NOW),
93
+ [read],
94
+ "a torrent being read still counts, hurry or not"
95
+ );
96
+ });
97
+
98
+ test("a torrent nobody is reading is not treated as starving", () => {
99
+ // The encoder is suspended once it is far enough ahead, and while it is
100
+ // suspended nothing is requested — so the download reads zero without anyone
101
+ // waiting. Measured before this guard: four cycles of 512 -> 50 KB/s in three
102
+ // minutes, every one of them reported as `earn unchoke ... down=0KB/s`.
103
+ const idleButChoked = {
104
+ name: "film.mkv",
105
+ hasActiveReader: false,
106
+ wires: [
107
+ { amInterested: true, peerChoking: true },
108
+ { amInterested: true, peerChoking: true }
109
+ ],
110
+ downloadSpeed: 0,
111
+ done: false
112
+ };
113
+ assert.equal(decideUploadLimit([idleButChoked], { now: NOW }).bytesPerSec, 50 * 1024);
114
+
115
+ const waiting = { ...idleButChoked, hasActiveReader: true };
116
+ assert.equal(
117
+ decideUploadLimit([waiting], { now: NOW }).bytesPerSec,
118
+ 512 * 1024,
119
+ "a reader that IS waiting must still earn unchoke slots"
120
+ );
121
+ });