@torrent-tv/proxy 2.51.0 → 2.52.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,11 @@
1
+ ## 2.52.0
2
+
3
+ - **Fix**: A file opened at a position puts the SOUND there too. Where the audio rendition starts is computed from where the viewer is, and that reading consulted only two things — a position seeked to, and the last segment the session had served — both written by events that have not happened yet at the moment a file is opened partway through. The answer was therefore zero. Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address bar at 52:07: the picture session was created at `start=3130s` and ran from segment #781, and half a second later the audio rendition was created at `start=0s` with no `-ss` at all and set about re-encoding the film from the beginning. The player asked both for #782; the picture had it, the sound reached 57.5 s of 3130 in the 45 s the request lasted and then answered 404 — which the viewer was shown as "the proxy accepted the request but sent no video". The position a session was OPENED at is now the third reading, and `resolveViewerPosition` is pure and tested. The same calculation prepares a track for a language change, so that case is covered by the same fix.
4
+ - **Fix**: The line describing a swarm answers the question it is asked. It printed `peers=N` beside `wires=?`, which reads as two quantities of which one is unknown — while WebTorrent's `numPeers` IS `wires.length` (`lib/torrent.js`, identically in 2.8.5 and 3.0.21), so the first was the connection count and the second was a field that has never printed anything in any line it has ever written: the torrent lives on a worker thread and that property does not exist on the side doing the printing. What was missing was the other half of the question, and it is now there — how many peer addresses the client HOLDS, how many are queued to be tried, and what the tracker said the swarm has. Five offered and none connected is a connectivity fault; nobody offered is a supply fault; they need opposite investigations and one line now tells them apart. What the trackers said is kept PER TRACKER and reported as the best answer any of them gave: they answer separately, and a dead one replying `0` after a live one replied `500` would otherwise turn "several offered" into "nobody offered", inverting the very distinction being drawn.
5
+ - **New**: The wait for the first connected peer is measured, said once when it ends, and carried in the stats while it is still going. Measured 2026-08-21 on `JUFD665.mp4`: the tracker answered `seeders=5` at 13:40:30 and the first wire arrived at 13:44:47 — 4 min 17 s of a viewer watching an unexplained wait, after which the file's 12 MiB of edges arrived at 6.8 MB/s and the plan finished in three seconds. The whole cold start was that one number, and it was neither counted nor shown. The watching is attached to what `add` returns rather than inside its ready callback, because for a magnet everything it watches happens before `ready`: peer discovery starts before the metadata arrives, so the trackers' answers land before any listener exists, and the peer that DELIVERED the metadata connected before `ready` fired — `wire` is emitted on connection and never replayed. It is also attached once per torrent: WebTorrent answers a duplicate add by handing back the torrent it already has, and re-attaching reset the timing of a live swarm, after which the next connection would print "first peer connected after 0.3s" about a torrent that had been connected for minutes.
6
+ - **Fix**: One name for a torrent, and it is the infohash. The pool's `added`, `announce` and `warning` lines were labelled with the first eight characters of a sha1 of the SOURCE BYTES, the upload lines used the infohash, and the stats line used the registry's own key — three different hashes of one film, printed in the same second, none matching. The infohash is now on all of them, and on every stats line rather than only on the ones that look empty.
7
+ - **Chore**: `askedFor=0` is called `fileIndex=0`. It is the index of the file being asked about, and it was printed under a name that reads as "nothing was asked for" — in a line whose subject is a download that is not happening.
8
+
1
9
  ## 2.51.0
2
10
 
3
11
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.51.0",
3
+ "version": "2.52.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": {
@@ -61,6 +61,37 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
61
61
  stats.headerBytes != null
62
62
  ? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
63
63
  : "n/a";
64
+ // The swarm, said so that the question this line is asked can be answered
65
+ // from it. "Connected" and "known" are different numbers and their
66
+ // difference is the diagnosis: a tracker offering five while we are
67
+ // connected to none is a connectivity fault, and nobody offering anything is
68
+ // a supply fault. Until 2026-08-21 the line printed `peers=` beside
69
+ // `wires=?` — two quantities of which one looked unknown, while in truth the
70
+ // first WAS the connection count and the second was a field that never
71
+ // printed anything, because the torrent lives on another thread and the
72
+ // property does not exist on this side of it.
73
+ const known = stats.knownPeers === null || stats.knownPeers === undefined ? "?" : stats.knownPeers;
74
+ const queued = stats.queuedPeers === null || stats.queuedPeers === undefined ? "?" : stats.queuedPeers;
75
+ // The BEST answer any tracker gave, and how many answered — not the most
76
+ // recent one. Trackers answer separately, and a dead one replying `0` after a
77
+ // live one replied `500` would otherwise turn "several offered" into "nobody
78
+ // offered", which is the distinction this whole line exists to make.
79
+ const offered = stats.trackerSeeders === null || stats.trackerSeeders === undefined
80
+ ? (stats.trackersAnswered
81
+ // Answered, and none of them knew of anybody — a different state from
82
+ // "no tracker has replied at all", and the line must not read as the
83
+ // second when it means the first.
84
+ ? `${stats.trackersAnswered} tracker(s) answered, none reported a count`
85
+ : "no tracker answer yet")
86
+ : `${stats.trackersAnswered ?? "?"} tracker(s) offered up to ${stats.trackerSeeders} seeders ` +
87
+ `${stats.trackerLeechers ?? "?"} leechers`;
88
+ // The wait for a first peer can be the whole of a cold start — 257 s of it,
89
+ // measured — and it was neither counted nor shown.
90
+ const firstPeer = Number.isFinite(stats.secondsToFirstPeer)
91
+ ? ` firstPeerAfter=${stats.secondsToFirstPeer.toFixed(1)}s`
92
+ : Number.isFinite(stats.secondsWaitingForFirstPeer)
93
+ ? ` noPeerFor=${stats.secondsWaitingForFirstPeer.toFixed(1)}s`
94
+ : "";
64
95
  // When the answer is empty, say WHICH thing is missing. Field 2026-08-05: a
65
96
  // source reported `peers=0 file=n/a header=n/a` for minutes while that very
66
97
  // torrent was announcing to trackers with hundreds of seeders — and the line
@@ -70,12 +101,17 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
70
101
  // answerable from the log rather than by reasoning about it afterwards.
71
102
  const emptyAnswer = stats.fileProgress == null || (stats.numPeers === 0 && torrent.done !== true);
72
103
  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}`
104
+ ? ` | files=${torrent.files?.length ?? "?"}` +
105
+ ` fileIndex=${fileIndex ?? "none"} resolved=${torrent.files?.[fileIndex ?? -1] ? "yes" : "no"}` +
106
+ ` done=${torrent.done === true}`
76
107
  : "";
108
+ // The infohash is on EVERY line, not only on the ones that look empty: it is
109
+ // the one identifier the pool's own lines, the worker's and this one share,
110
+ // and a line that carries it can be lined up with them without a guess.
77
111
  logger.info(
78
- `[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}${detail}`
112
+ `[stats] ${sourceKey.slice(0, 8)} ${String(torrent.infoHash).slice(0, 8)} ` +
113
+ `peers=${stats.connectedPeers ?? stats.numPeers} connected of ${known} known (${queued} queued, ${offered})` +
114
+ `${firstPeer} down=${downKbps}KB/s file=${filePct} header=${header}${detail}`
79
115
  );
80
116
 
81
117
  return reply.send(stats);
@@ -1182,6 +1182,49 @@ export function segmentCutTimesFrom(boundaries, startIndex) {
1182
1182
  return times;
1183
1183
  }
1184
1184
 
1185
+ /**
1186
+ * Where the viewer is, from the three things that can say so.
1187
+ *
1188
+ * In order, because each is a better answer than the next and each may be
1189
+ * absent:
1190
+ *
1191
+ * 1. a position they seeked to — they said it themselves;
1192
+ * 2. the start of the last segment this session actually served — where the
1193
+ * reading is;
1194
+ * 3. **the position the file was OPENED at.**
1195
+ *
1196
+ * The third used to be missing, and its absence made the answer zero at exactly
1197
+ * the moment it is asked. Both of the others are written by things that have
1198
+ * not happened yet when a file is opened at a position — the first by a seek,
1199
+ * the second by a segment served — so a session created to begin at 3130 s
1200
+ * answered "the viewer is at the beginning". That is not a cautious default; it
1201
+ * is a wrong answer, and the soundtrack acts on it.
1202
+ *
1203
+ * Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address
1204
+ * bar at 52:07: the picture session was created at `start=3130s` and ran from
1205
+ * segment #781; half a second later the audio rendition was created from this
1206
+ * reading — `start=0s`, no `-ss` at all — and set about re-encoding the film
1207
+ * from the beginning. The player asked both for #782. The picture had it. The
1208
+ * sound reached 57.5 s of 3130 in the 45 s the request lasted, then answered
1209
+ * 404, which the viewer was shown as "the proxy accepted the request but sent
1210
+ * no video".
1211
+ *
1212
+ * @param {{ seeked?: number, lastRequestedStart?: number | null, openedAt?: number }} readings
1213
+ * @returns {number} Seconds, never negative.
1214
+ */
1215
+ export function resolveViewerPosition({ seeked, lastRequestedStart, openedAt }) {
1216
+ if (Number.isFinite(seeked) && seeked > 0) {
1217
+ return seeked;
1218
+ }
1219
+ if (Number.isFinite(lastRequestedStart) && lastRequestedStart > 0) {
1220
+ return lastRequestedStart;
1221
+ }
1222
+ if (Number.isFinite(openedAt) && openedAt > 0) {
1223
+ return openedAt;
1224
+ }
1225
+ return 0;
1226
+ }
1227
+
1185
1228
  /**
1186
1229
  * How far the live boundary table has moved from the one the player holds, said
1187
1230
  * in words.
@@ -6927,13 +6970,14 @@ export class HlsSessionManager {
6927
6970
  }
6928
6971
 
6929
6972
  #viewerPositionOf(session) {
6930
- if (Number.isFinite(session.viewerPositionSeconds) && session.viewerPositionSeconds > 0) {
6931
- return session.viewerPositionSeconds;
6932
- }
6933
- if (Number.isInteger(session.lastRequestedSegment) && session.lastRequestedSegment > 0) {
6934
- return this.#segmentStartTime(session, session.lastRequestedSegment);
6935
- }
6936
- return 0;
6973
+ const lastRequestedStart = Number.isInteger(session.lastRequestedSegment) && session.lastRequestedSegment > 0
6974
+ ? this.#segmentStartTime(session, session.lastRequestedSegment)
6975
+ : null;
6976
+ return resolveViewerPosition({
6977
+ seeked: session.viewerPositionSeconds,
6978
+ lastRequestedStart,
6979
+ openedAt: session.progress?.startPositionSeconds
6980
+ });
6937
6981
  }
6938
6982
 
6939
6983
  /**
@@ -134,6 +134,100 @@ const STALL_SPEED_BYTES = 32 * 1024;
134
134
  const STALL_REPORT_AFTER_MS = 10_000;
135
135
  const STALL_REPORT_INTERVAL_MS = 30_000;
136
136
 
137
+ /**
138
+ * How far this torrent has got towards HAVING a swarm: connected, known, and
139
+ * waiting to be tried.
140
+ *
141
+ * The question a stalled download is asked is "were we offered anybody", and
142
+ * until 2026-08-21 nothing could answer it. The line printed `peers=` beside
143
+ * `wires=?`, which read as two quantities of which one was unknown — while in
144
+ * fact WebTorrent's `numPeers` IS `wires.length` (`lib/torrent.js`, both 2.8.5
145
+ * and 3.0.21), so the first was the connection count and the second was a
146
+ * field that has never printed anything. What was missing is the other side:
147
+ * how many peer addresses the client HOLDS but is not connected to. A tracker
148
+ * answering `seeders=5` while `connected=0, known=0` is a different fault from
149
+ * `connected=0, known=5`, and only the second is about connecting.
150
+ *
151
+ * `_peersLength` and `_numQueued` are WebTorrent internals, not its published
152
+ * interface — a cached counter and a getter in 2.8.5, both getters in 3.0.21,
153
+ * read the same way in each. Read defensively on purpose: if a later version
154
+ * drops them the field says nothing rather than breaking a poll the browser
155
+ * makes every two seconds.
156
+ *
157
+ * @param {import("webtorrent").Torrent} torrent
158
+ * @returns {{ connectedPeers: number, knownPeers: number | null, queuedPeers: number | null }}
159
+ */
160
+ export function describeSwarmReach(torrent) {
161
+ const wires = Array.isArray(torrent?.wires) ? torrent.wires.length : 0;
162
+ const read = (value) => (typeof value === "number" && Number.isFinite(value) ? value : null);
163
+ let knownPeers = null;
164
+ let queuedPeers = null;
165
+ try {
166
+ knownPeers = read(torrent?._peersLength);
167
+ queuedPeers = read(torrent?._numQueued);
168
+ } catch {
169
+ // silent-ok: these are internals behind getters that can throw on a
170
+ // destroyed torrent, and the reading is a diagnostic. Nothing here is
171
+ // worth failing a stats poll for.
172
+ }
173
+ return { connectedPeers: wires, knownPeers, queuedPeers };
174
+ }
175
+
176
+ /**
177
+ * How long a torrent waited for its first connected peer.
178
+ *
179
+ * The whole of a cold start can be this one number and nothing else: measured
180
+ * 2026-08-21, a tracker answered `seeders=5` at 13:40:30 and the first wire
181
+ * arrived at 13:44:47 — 257 s during which the stats line repeated, unchanged,
182
+ * every two seconds. Once the peer connected the file's edges arrived at
183
+ * 6.8 MB/s and the plan finished in three seconds. It was never measured,
184
+ * never named, and the viewer saw it as an unexplained wait.
185
+ *
186
+ * @param {number} addedAtMs
187
+ * @param {number} firstPeerAtMs
188
+ * @returns {number | null} Seconds, or null while there is still no peer or
189
+ * the two moments cannot be compared.
190
+ */
191
+ export function secondsToFirstPeer(addedAtMs, firstPeerAtMs) {
192
+ if (!Number.isFinite(addedAtMs) || !Number.isFinite(firstPeerAtMs)) {
193
+ return null;
194
+ }
195
+ const seconds = (firstPeerAtMs - addedAtMs) / 1000;
196
+ return seconds >= 0 ? Number(seconds.toFixed(3)) : null;
197
+ }
198
+
199
+ /**
200
+ * The best answer any tracker has given, out of the answers they have given.
201
+ *
202
+ * A torrent announces to every tracker it lists and each answers separately,
203
+ * so keeping "the last one" makes the reading depend on which tracker replied
204
+ * most recently. A live tracker saying `complete=500` followed two seconds
205
+ * later by a dead one saying `complete=0` would print "nobody offered" — and
206
+ * telling that from "several offered and we reached none" is the entire reason
207
+ * this figure is carried. The best answer is the honest one: a swarm has as
208
+ * many seeders as the most informed tracker knows about.
209
+ *
210
+ * @param {Iterable<{ seeders: number | null, leechers: number | null }>} answers
211
+ * @returns {{ seeders: number | null, leechers: number | null, trackers: number }}
212
+ */
213
+ export function bestAnnounce(answers) {
214
+ let seeders = null;
215
+ let leechers = null;
216
+ let trackers = 0;
217
+ for (const answer of answers ?? []) {
218
+ trackers += 1;
219
+ if (typeof answer?.seeders === "number" && (seeders === null || answer.seeders > seeders)) {
220
+ seeders = answer.seeders;
221
+ // Taken from the SAME answer, including when that answer gave no leecher
222
+ // count. Carrying the previous tracker's figure forward would pair one
223
+ // tracker's seeders with another's leechers and present the pair as one
224
+ // reading.
225
+ leechers = typeof answer.leechers === "number" ? answer.leechers : null;
226
+ }
227
+ }
228
+ return { seeders, leechers, trackers };
229
+ }
230
+
137
231
  /**
138
232
  * What the swarm has been asked for, and what it is doing about it.
139
233
  *
@@ -655,6 +749,26 @@ export class TorrentPool {
655
749
  /** One-shot timer that reports the size of the DHT's routing table. */
656
750
  #dhtReportTimer = null;
657
751
 
752
+ /**
753
+ * The last announce answer per torrent — what the TRACKER says the swarm
754
+ * holds, as opposed to what we have managed to connect to. Kept because the
755
+ * two disagreeing is the whole diagnosis: five seeders offered and none
756
+ * connected is a connectivity fault, and nobody offered is a supply fault.
757
+ *
758
+ * Kept PER TRACKER, because each answers for itself and the most recent
759
+ * answer is not the most informed one.
760
+ *
761
+ * @type {WeakMap<import("webtorrent").Torrent, Map<string, { seeders: number | null, leechers: number | null, at: number }>>}
762
+ */
763
+ #lastAnnounceByTorrent = new WeakMap();
764
+
765
+ /**
766
+ * When each torrent was added, and when its first peer connected.
767
+ *
768
+ * @type {WeakMap<import("webtorrent").Torrent, { addedAt: number, firstPeerAt: number | null }>}
769
+ */
770
+ #swarmTimingByTorrent = new WeakMap();
771
+
658
772
  /**
659
773
  * @param {{ maxDiskBytes?: number }} [options]
660
774
  * `maxDiskBytes` caps total downloaded torrent data; when omitted a
@@ -775,7 +889,10 @@ export class TorrentPool {
775
889
  }
776
890
  torrent.hurryUntil = until;
777
891
  logger.info(
778
- `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] uploading generously for ` +
892
+ // `?? "?"` because one caller is the moment of adding, and `client.add`
893
+ // returns before the torrent id has been parsed — without it the line
894
+ // printed the first eight characters of the word "undefined".
895
+ `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] uploading generously for ` +
779
896
  `${Math.round(UPLOAD_HURRY_MS / 1000)}s — ${why}`
780
897
  );
781
898
  this.#adjustUploadLimit();
@@ -1083,24 +1200,102 @@ export class TorrentPool {
1083
1200
  * warnings (tracker rejections/errors surface here). Without these a
1084
1201
  * zero-peer torrent gives no clue WHY it has no peers.
1085
1202
  *
1086
- * @param {string} label - Short source label for log lines.
1087
1203
  * @param {import("webtorrent").Torrent} torrent
1088
1204
  * @returns {void}
1089
1205
  */
1090
- #attachSwarmDiagnostics(label, torrent) {
1206
+ #attachSwarmDiagnostics(torrent) {
1207
+ // Attached ONCE per torrent. WebTorrent answers a duplicate add by handing
1208
+ // back the torrent it already has, and this used to run again on it: a
1209
+ // torrent with twelve connections and a first peer five minutes old had its
1210
+ // timing record reset, so it began reporting "no peer yet" and the next
1211
+ // connection printed "first peer connected after 0.3s" — a false statement
1212
+ // about a swarm that had been healthy for minutes. The same film opened
1213
+ // once as a .torrent and once as a magnet is exactly that case, and the
1214
+ // roadmap already records it happening.
1215
+ if (this.#swarmTimingByTorrent.has(torrent)) {
1216
+ return;
1217
+ }
1091
1218
  // A torrent nobody has asked for yet does not exist: this is called the
1092
1219
  // moment one is added, which is the moment a viewer started waiting.
1093
1220
  this.#markHurry(torrent, "just added");
1094
- const trackerCount = Array.isArray(torrent.announce) ? torrent.announce.length : 0;
1095
- logger.info(
1096
- `torrent-pool: [${label}] added: files=${torrent.files?.length ?? 0} ` +
1097
- `private=${torrent.private ? "yes" : "no"} trackers=${trackerCount}`
1098
- );
1221
+ // ONE name for a torrent, and it is the infohash. These lines used to be
1222
+ // labelled with the first eight characters of a sha1 of the SOURCE BYTES,
1223
+ // while the neighbouring lines used the infohash and the stats line used
1224
+ // the registry's own key — three different hashes of one film, in the same
1225
+ // second, none of them matching. Correlating a swarm across three lines
1226
+ // cost a guess every time. The infohash is the one identifier every side
1227
+ // of this system already shares.
1228
+ //
1229
+ // Read at the moment of PRINTING, not now. `client.add` returns before the
1230
+ // torrent id has been parsed — measured against the vendored 2.8.5, both a
1231
+ // magnet and a .torrent buffer have `infoHash === undefined` on the line
1232
+ // after `add` returns — so a label captured here would be the string "?"
1233
+ // for the whole life of the torrent, which is the same fault as three
1234
+ // different hashes with the hash removed.
1235
+ const label = () => String(torrent.infoHash ?? "?").slice(0, 8);
1236
+ const addedAt = Date.now();
1237
+ this.#swarmTimingByTorrent.set(torrent, { addedAt, firstPeerAt: null });
1238
+
1239
+ // The first connected peer, said once, because the wait for it can BE the
1240
+ // whole cold start and nothing else measures it.
1241
+ torrent.on("wire", () => {
1242
+ const timing = this.#swarmTimingByTorrent.get(torrent);
1243
+ if (!timing || timing.firstPeerAt !== null) {
1244
+ return;
1245
+ }
1246
+ timing.firstPeerAt = Date.now();
1247
+ const waited = secondsToFirstPeer(timing.addedAt, timing.firstPeerAt);
1248
+ const byTracker = this.#lastAnnounceByTorrent.get(torrent);
1249
+ const offered = bestAnnounce(byTracker ? byTracker.values() : []);
1250
+ logger.info(
1251
+ `torrent-pool: [${label()}] first peer connected after ${waited === null ? "?" : waited.toFixed(1)}s` +
1252
+ (offered.trackers > 0
1253
+ ? ` (${offered.trackers} tracker(s) had answered, best seeders=${offered.seeders ?? "?"} ` +
1254
+ `leechers=${offered.leechers ?? "?"})`
1255
+ : " (no tracker answer had arrived)")
1256
+ );
1257
+ });
1099
1258
 
1100
1259
  torrent.on("warning", (warning) => {
1101
- logger.warn(`torrent-pool: [${label}] warning: ${formatWarning(warning)}`);
1260
+ logger.warn(`torrent-pool: [${label()}] warning: ${formatWarning(warning)}`);
1102
1261
  });
1103
1262
 
1263
+ // Everything below needs the torrent to have been PARSED, and `add` returns
1264
+ // before that: `announce`, `files` and `private` are all still empty, and
1265
+ // `discovery` — which owns the tracker client — is not created until
1266
+ // `_startDiscovery`, which runs immediately before `ready` is emitted.
1267
+ // Attaching the tracker listener at add-time therefore attaches it to
1268
+ // nothing at all, and every announce answer is lost. The two listeners
1269
+ // above stay where they are, because `wire` and `warning` live on the
1270
+ // torrent from construction and both fire before `ready`: the peer that
1271
+ // DELIVERS a magnet's metadata connects first, and `wire` is emitted on
1272
+ // connection and never replayed.
1273
+ const describeOnce = () => {
1274
+ const trackerCount = Array.isArray(torrent.announce) ? torrent.announce.length : 0;
1275
+ logger.info(
1276
+ `torrent-pool: [${label()}] added: files=${torrent.files?.length ?? 0} ` +
1277
+ `private=${torrent.private ? "yes" : "no"} trackers=${trackerCount}`
1278
+ );
1279
+ this.#attachTrackerDiagnostics(torrent, label);
1280
+ };
1281
+ if (torrent.ready === true) {
1282
+ describeOnce();
1283
+ } else {
1284
+ torrent.once("ready", describeOnce);
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * Watch what the trackers answer.
1290
+ *
1291
+ * Separated from the rest because it can only be done once the torrent has
1292
+ * been parsed — see the reasoning at the call site.
1293
+ *
1294
+ * @param {import("webtorrent").Torrent} torrent
1295
+ * @param {() => string} label
1296
+ * @returns {void}
1297
+ */
1298
+ #attachTrackerDiagnostics(torrent, label) {
1104
1299
  // bittorrent-tracker's Client emits "update" with each announce response.
1105
1300
  // `complete`/`incomplete` are the tracker's seeder/leecher counts — the
1106
1301
  // authoritative answer to "does the tracker accept us and does the swarm
@@ -1112,13 +1307,25 @@ export class TorrentPool {
1112
1307
  // strip the query string before logging.
1113
1308
  const announceUrl =
1114
1309
  typeof data?.announce === "string" ? data.announce.replace(/\?.*$/, "") : "?";
1310
+ const seeders = typeof data?.complete === "number" ? data.complete : null;
1311
+ const leechers = typeof data?.incomplete === "number" ? data.incomplete : null;
1312
+ // Kept, not only printed: what the tracker says the swarm holds is one
1313
+ // half of every later question about why nothing is arriving, and a
1314
+ // number that exists only in a log line cannot be put beside the other
1315
+ // half two minutes later.
1316
+ let byTracker = this.#lastAnnounceByTorrent.get(torrent);
1317
+ if (!byTracker) {
1318
+ byTracker = new Map();
1319
+ this.#lastAnnounceByTorrent.set(torrent, byTracker);
1320
+ }
1321
+ byTracker.set(announceUrl, { seeders, leechers, at: Date.now() });
1115
1322
  logger.info(
1116
- `torrent-pool: [${label}] announce ${announceUrl}: ` +
1117
- `seeders=${data?.complete ?? "?"} leechers=${data?.incomplete ?? "?"}`
1323
+ `torrent-pool: [${label()}] announce ${announceUrl}: ` +
1324
+ `seeders=${seeders ?? "?"} leechers=${leechers ?? "?"}`
1118
1325
  );
1119
1326
  });
1120
1327
  } else {
1121
- logger.info(`torrent-pool: [${label}] tracker client not exposed; announce results not logged`);
1328
+ logger.info(`torrent-pool: [${label()}] tracker client not exposed; announce results not logged`);
1122
1329
  }
1123
1330
  }
1124
1331
 
@@ -1219,17 +1426,18 @@ export class TorrentPool {
1219
1426
  this.#lastAccess.delete(existing);
1220
1427
  this.#readPositionByTorrent.delete(existing);
1221
1428
  this.client.remove(existing, { destroyStore: true }, () => {
1222
- this.client.add(torrentId, {
1429
+ const addedReplacement = this.client.add(torrentId, {
1223
1430
  store: SharedPieceStore,
1224
1431
  storeCacheSlots: 0,
1225
1432
  storeOpts: { memoryBytes: this.#memoryBytes }
1226
1433
  }, (replacement) => {
1227
1434
  this.torrents.set(key, replacement);
1228
1435
  this.#lastAccess.set(replacement, Date.now());
1229
- this.#attachSwarmDiagnostics(dupMatch[1].slice(0, 8), replacement);
1230
1436
  this.#pending.delete(key);
1437
+ this.#attachSwarmDiagnostics(replacement);
1231
1438
  resolve(replacement);
1232
1439
  });
1440
+ this.#attachSwarmDiagnostics(addedReplacement);
1233
1441
  });
1234
1442
  return;
1235
1443
  }
@@ -1250,7 +1458,7 @@ export class TorrentPool {
1250
1458
  // piece across threads detached memory still in use. Ours owns what it
1251
1459
  // hands out, holds pieces in shared memory the main thread can read
1252
1460
  // directly, and spills to disk instead of losing them.
1253
- this.client.add(torrentId, {
1461
+ const added = this.client.add(torrentId, {
1254
1462
  store: SharedPieceStore,
1255
1463
  storeCacheSlots: 0,
1256
1464
  storeOpts: { memoryBytes: this.#memoryBytes }
@@ -1259,11 +1467,23 @@ export class TorrentPool {
1259
1467
  this.torrents.set(key, readyTorrent);
1260
1468
  this.#lastAccess.set(readyTorrent, Date.now());
1261
1469
  this.#pending.delete(key);
1262
- // Key layout is `${sourceType}:${sha1}`; log with the sha1 prefix so
1263
- // lines correlate with the [stats] source key.
1264
- this.#attachSwarmDiagnostics(key.split(":")[1]?.slice(0, 8) ?? key, readyTorrent);
1470
+ // The torrent this call ends up with is not always the one it added:
1471
+ // on a duplicate infohash WebTorrent destroys the new one and hands
1472
+ // back the one it already had. Watching only what `add` returned would
1473
+ // leave the survivor unwatched. Attaching twice costs nothing — the
1474
+ // guard makes the second call a no-op when it is the same object.
1475
+ this.#attachSwarmDiagnostics(readyTorrent);
1265
1476
  resolve(readyTorrent);
1266
1477
  });
1478
+ // Attached to what `add` returns, NOT inside its callback. That callback
1479
+ // is `torrent.once("ready")`, and for a magnet everything this watches
1480
+ // has already happened by then: peer discovery starts before the metadata
1481
+ // arrives, so the tracker's answers land before any listener exists, and
1482
+ // the peer that DELIVERED the metadata connected before `ready` fired —
1483
+ // `wire` is emitted at the moment of connection and never replayed. The
1484
+ // torrent that waited minutes for its first peer would have been the one
1485
+ // case this could not measure.
1486
+ this.#attachSwarmDiagnostics(added);
1267
1487
  });
1268
1488
 
1269
1489
  this.#pending.set(key, promise);
@@ -1439,7 +1659,15 @@ export class TorrentPool {
1439
1659
  * uploadSpeed: number,
1440
1660
  * fileProgress: number | null,
1441
1661
  * fileDownloaded: number | null,
1442
- * fileLength: number | null
1662
+ * fileLength: number | null,
1663
+ * connectedPeers: number,
1664
+ * knownPeers: number | null,
1665
+ * queuedPeers: number | null,
1666
+ * trackerSeeders: number | null,
1667
+ * trackerLeechers: number | null,
1668
+ * trackersAnswered: number,
1669
+ * secondsToFirstPeer: number | null,
1670
+ * secondsWaitingForFirstPeer: number | null
1443
1671
  * }}
1444
1672
  */
1445
1673
  getFileStats(torrent, fileIndex = null, options = {}) {
@@ -1447,7 +1675,34 @@ export class TorrentPool {
1447
1675
  const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
1448
1676
  const uploadSpeed = typeof torrent?.uploadSpeed === "number" ? torrent.uploadSpeed : 0;
1449
1677
 
1450
- const base = { numPeers, downloadSpeed, uploadSpeed };
1678
+ // Everything the caller needs to tell "nobody was offered" from "several
1679
+ // were offered and we connected to none". All of it is read HERE, on the
1680
+ // thread that owns the torrent: the handle the routes hold is a proxy
1681
+ // across a worker boundary, where `torrent.wires` simply does not exist —
1682
+ // which is why the line that tried to print it printed a question mark in
1683
+ // every line it has ever written.
1684
+ const reach = describeSwarmReach(torrent);
1685
+ const byTracker = this.#lastAnnounceByTorrent.get(torrent);
1686
+ const announce = bestAnnounce(byTracker ? byTracker.values() : []);
1687
+ const timing = this.#swarmTimingByTorrent.get(torrent) ?? null;
1688
+
1689
+ const base = {
1690
+ numPeers,
1691
+ downloadSpeed,
1692
+ uploadSpeed,
1693
+ connectedPeers: reach.connectedPeers,
1694
+ knownPeers: reach.knownPeers,
1695
+ queuedPeers: reach.queuedPeers,
1696
+ trackerSeeders: announce.seeders,
1697
+ trackerLeechers: announce.leechers,
1698
+ trackersAnswered: announce.trackers,
1699
+ secondsToFirstPeer: timing ? secondsToFirstPeer(timing.addedAt, timing.firstPeerAt) : null,
1700
+ // How long this torrent has been waiting, when it is still waiting. The
1701
+ // figure above answers "how long did it take"; this one answers "how long
1702
+ // has it been", which is the question during the wait itself.
1703
+ secondsWaitingForFirstPeer:
1704
+ timing && timing.firstPeerAt === null ? secondsToFirstPeer(timing.addedAt, Date.now()) : null
1705
+ };
1451
1706
 
1452
1707
  if (fileIndex === null || !Number.isInteger(fileIndex) || !Array.isArray(torrent?.files)) {
1453
1708
  return { ...base, fileProgress: null, fileDownloaded: null, fileLength: null };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file The line describing a swarm answers the question it is asked.
3
+ *
4
+ * It could not. `routes/api/sources/stats/get.js` printed `peers=N` beside
5
+ * `wires=?`, which reads as two quantities of which one is unknown — while
6
+ * WebTorrent's `numPeers` IS `wires.length` (`lib/torrent.js:254`, the same in
7
+ * 2.8.5 and 3.0.21), so the first was the connection count and the second was a
8
+ * field that has never printed anything, because the torrent lives on a worker
9
+ * thread where that property does not exist.
10
+ *
11
+ * What was missing is the other half: how many peers the client HOLDS but is
12
+ * not connected to, and what the tracker said the swarm has. Measured
13
+ * 2026-08-21 on `JUFD665.mp4`, the tracker answered `seeders=5` at 13:40:30 and
14
+ * the first wire arrived at 13:44:47 — 4 min 17 s in which the stats line
15
+ * repeated unchanged every two seconds while the answer ("offered, not
16
+ * connected") was already in the process.
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import test from "node:test";
21
+
22
+ import { bestAnnounce, describeSwarmReach, secondsToFirstPeer } from "../services/torrent-pool.js";
23
+
24
+ test("connected and known are separate numbers", () => {
25
+ const torrent = { wires: [{}, {}], _peersLength: 7, _numQueued: 4 };
26
+ assert.deepEqual(describeSwarmReach(torrent), {
27
+ connectedPeers: 2,
28
+ knownPeers: 7,
29
+ queuedPeers: 4
30
+ });
31
+ });
32
+
33
+ test("offered but not connected is distinguishable from nobody offered", () => {
34
+ // The two shapes the field produced. They need opposite investigations, and
35
+ // one line has to tell them apart.
36
+ const offeredNotConnected = describeSwarmReach({ wires: [], _peersLength: 5, _numQueued: 5 });
37
+ const nobodyOffered = describeSwarmReach({ wires: [], _peersLength: 0, _numQueued: 0 });
38
+ assert.equal(offeredNotConnected.connectedPeers, 0);
39
+ assert.equal(nobodyOffered.connectedPeers, 0);
40
+ assert.notEqual(offeredNotConnected.knownPeers, nobodyOffered.knownPeers);
41
+ });
42
+
43
+ test("internals that are gone say nothing rather than breaking the poll", () => {
44
+ // `_peersLength` and `_numQueued` are not WebTorrent's published interface.
45
+ // The browser polls this every two seconds, so a version that drops them must
46
+ // cost the field and not the answer.
47
+ assert.deepEqual(describeSwarmReach({ wires: [{}] }), {
48
+ connectedPeers: 1,
49
+ knownPeers: null,
50
+ queuedPeers: null
51
+ });
52
+ assert.deepEqual(describeSwarmReach({}), {
53
+ connectedPeers: 0,
54
+ knownPeers: null,
55
+ queuedPeers: null
56
+ });
57
+ assert.deepEqual(describeSwarmReach(null), {
58
+ connectedPeers: 0,
59
+ knownPeers: null,
60
+ queuedPeers: null
61
+ });
62
+ const throwing = {
63
+ wires: [],
64
+ get _peersLength() {
65
+ throw new Error("destroyed");
66
+ }
67
+ };
68
+ assert.deepEqual(describeSwarmReach(throwing), {
69
+ connectedPeers: 0,
70
+ knownPeers: null,
71
+ queuedPeers: null
72
+ });
73
+ });
74
+
75
+ test("the wait for a first peer is a measured quantity", () => {
76
+ // The field case: added 13:40:30.357, first wire 13:44:47.123.
77
+ assert.equal(secondsToFirstPeer(1000, 258_766), 257.766);
78
+ assert.equal(secondsToFirstPeer(1000, 1000), 0);
79
+ });
80
+
81
+ test("no peer yet is not a duration", () => {
82
+ assert.equal(secondsToFirstPeer(1000, null), null);
83
+ assert.equal(secondsToFirstPeer(null, 2000), null);
84
+ // A clock that went backwards is not a negative wait; it is no reading.
85
+ assert.equal(secondsToFirstPeer(2000, 1000), null);
86
+ });
87
+
88
+ test("the best tracker answer wins, not the most recent", () => {
89
+ // A live tracker says 500, a dead one answers 0 two seconds later. Keeping
90
+ // the last would print "nobody offered" about a swarm of five hundred, which
91
+ // inverts the one distinction this figure is carried for.
92
+ const answers = [
93
+ { seeders: 500, leechers: 40 },
94
+ { seeders: 0, leechers: 0 }
95
+ ];
96
+ assert.deepEqual(bestAnnounce(answers), { seeders: 500, leechers: 40, trackers: 2 });
97
+ });
98
+
99
+ test("trackers that answered are counted even when none knew anything", () => {
100
+ assert.deepEqual(bestAnnounce([{ seeders: null, leechers: null }]), {
101
+ seeders: null,
102
+ leechers: null,
103
+ trackers: 1
104
+ });
105
+ // No tracker has answered at all — a different state from "answered, knows
106
+ // nobody", and the line says so.
107
+ assert.deepEqual(bestAnnounce([]), { seeders: null, leechers: null, trackers: 0 });
108
+ assert.deepEqual(bestAnnounce(null), { seeders: null, leechers: null, trackers: 0 });
109
+ });
110
+
111
+ test("leechers travel with the seeder count they were reported beside", () => {
112
+ const answers = [
113
+ { seeders: 2, leechers: 99 },
114
+ { seeders: 7, leechers: 3 }
115
+ ];
116
+ assert.equal(bestAnnounce(answers).leechers, 3);
117
+ // Including when the winning answer gave no leecher count: carrying the
118
+ // previous tracker's figure forward would present two trackers' numbers as
119
+ // one reading.
120
+ assert.deepEqual(bestAnnounce([{ seeders: 2, leechers: 99 }, { seeders: 7, leechers: null }]), {
121
+ seeders: 7,
122
+ leechers: null,
123
+ trackers: 2
124
+ });
125
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file Opening a file at a position puts the SOUND there too.
3
+ *
4
+ * The audio rendition is a session of its own, and where it starts is computed
5
+ * from where the viewer is. That reading had three sources and only two were
6
+ * consulted — a position seeked to, and the last segment this session served —
7
+ * both of which are written by things that have not happened yet at the moment
8
+ * a file is opened at a position. So the answer was zero.
9
+ *
10
+ * Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address
11
+ * bar at 52:07:
12
+ *
13
+ * 18:02:55.124 8ed85605 start=3130s -ss 3125.25 -an -map 0:v:0 -c:v copy
14
+ * 18:02:55.632 33b0b046 start=0s no -ss -vn -map 0:a:0 -c:a aac
15
+ *
16
+ * The picture went to segment #781, the sound to #0. The player asked both for
17
+ * #782; the picture had it, the sound re-encoded 57.5 s of a 3130 s film in the
18
+ * 45 s the request lasted and then answered 404 — shown to the viewer as "the
19
+ * proxy accepted the request but sent no video".
20
+ */
21
+
22
+ import assert from "node:assert/strict";
23
+ import test from "node:test";
24
+
25
+ import { resolveViewerPosition } from "../services/hls-session-manager.js";
26
+
27
+ test("a file opened at a position has its viewer at that position", () => {
28
+ // Nothing has been seeked and nothing served yet — the state at the instant
29
+ // the audio rendition is created.
30
+ assert.equal(resolveViewerPosition({ openedAt: 3130 }), 3130);
31
+ });
32
+
33
+ test("a seek beats everything else", () => {
34
+ assert.equal(
35
+ resolveViewerPosition({ seeked: 900, lastRequestedStart: 400, openedAt: 3130 }),
36
+ 900
37
+ );
38
+ });
39
+
40
+ test("what has been served beats where the file was opened", () => {
41
+ // The opening position is the oldest of the three readings: once a segment
42
+ // has been served, that is where the reading is.
43
+ assert.equal(resolveViewerPosition({ lastRequestedStart: 400, openedAt: 3130 }), 400);
44
+ });
45
+
46
+ test("with nothing to go on the answer is the beginning", () => {
47
+ assert.equal(resolveViewerPosition({}), 0);
48
+ assert.equal(resolveViewerPosition({ seeked: 0, lastRequestedStart: 0, openedAt: 0 }), 0);
49
+ // Values that are not readings must not become one.
50
+ assert.equal(resolveViewerPosition({ seeked: Number.NaN, openedAt: Number.NaN }), 0);
51
+ assert.equal(resolveViewerPosition({ seeked: -5, openedAt: -5 }), 0);
52
+ assert.equal(resolveViewerPosition({ lastRequestedStart: null, openedAt: undefined }), 0);
53
+ });