@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
|
@@ -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
|
-
? ` |
|
|
74
|
-
`
|
|
75
|
-
`
|
|
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)}
|
|
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
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
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
|
/**
|
package/services/torrent-pool.js
CHANGED
|
@@ -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
|
-
`
|
|
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(
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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=${
|
|
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
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
|
|
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
|
-
|
|
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
|
+
});
|